diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 96f846f..2111f17 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -2,12 +2,20 @@ name: CI/CD Pipeline on: push: - branches: [ main, develop, dev ] + branches: + - main + - dev + - "feature/**" pull_request: - branches: [ main, develop, dev ] + branches: + - main + - dev env: PYTHON_VERSION: "3.11" + REGISTRY_HOST: "10.10.0.10:3000" + WEB_IMAGE: "mostovik-web" + CELERY_IMAGE: "mostovik-celery" jobs: lint: @@ -17,36 +25,39 @@ jobs: steps: - name: Checkout code run: | - REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") - git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . - git checkout ${GITHUB_SHA} + REPO_URL=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch="${GITHUB_REF_NAME}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . + git checkout "${GITHUB_SHA}" - name: Install Python and uv run: | - apt-get update && apt-get install -y software-properties-common + set -euo pipefail + apt-get update + apt-get install -y software-properties-common add-apt-repository -y ppa:deadsnakes/ppa - apt-get update && apt-get install -y python3.11 python3.11-venv + apt-get update + apt-get install -y python3.11 python3.11-venv curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="$HOME/.local/bin:$PATH" - name: Create virtual environment and install dependencies run: | + set -euo pipefail export PATH="$HOME/.local/bin:$PATH" uv venv --python python3.11 - source .venv/bin/activate - uv sync --dev + . .venv/bin/activate + uv sync --dev --frozen - name: Run Ruff linting run: | - export PATH="$HOME/.local/bin:$PATH" - source .venv/bin/activate - ruff check src/ + set -euo pipefail + . .venv/bin/activate + ruff check src tests - name: Run Ruff formatting check run: | - export PATH="$HOME/.local/bin:$PATH" - source .venv/bin/activate - ruff format src/ --check + set -euo pipefail + . .venv/bin/activate + ruff format src tests --check test: name: Run Tests @@ -55,171 +66,183 @@ jobs: steps: - name: Checkout code run: | - REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") - git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . - git checkout ${GITHUB_SHA} + REPO_URL=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch="${GITHUB_REF_NAME}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . + git checkout "${GITHUB_SHA}" - name: Install Python and uv run: | - apt-get update && apt-get install -y software-properties-common + set -euo pipefail + apt-get update + apt-get install -y software-properties-common add-apt-repository -y ppa:deadsnakes/ppa - apt-get update && apt-get install -y python3.11 python3.11-venv + apt-get update + apt-get install -y python3.11 python3.11-venv curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="$HOME/.local/bin:$PATH" - name: Create virtual environment and install dependencies run: | + set -euo pipefail export PATH="$HOME/.local/bin:$PATH" uv venv --python python3.11 - source .venv/bin/activate - uv sync --dev + . .venv/bin/activate + uv sync --dev --frozen - name: Run Django tests - run: | - export PATH="$HOME/.local/bin:$PATH" - source .venv/bin/activate - export PYTHONPATH="${PWD}/src:${PYTHONPATH}" - python src/manage.py test tests --verbosity=2 env: DJANGO_SETTINGS_MODULE: config.settings.test SECRET_KEY: test-secret-key-for-ci + run: | + set -euo pipefail + . .venv/bin/activate + export PYTHONPATH="${PWD}/src:${PYTHONPATH}" + python src/manage.py test tests --verbosity=2 - build: - name: Build Docker Images + build_push: + name: Build & Push Images runs-on: ubuntu-latest needs: [lint, test] + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') steps: - name: Checkout code run: | - REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") - git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . - git checkout ${GITHUB_SHA} - - - name: Build web image - run: | - BRANCH_TAG=$(echo ${GITHUB_REF_NAME} | sed 's/\//-/g') - SHA_SHORT=$(echo ${GITHUB_SHA} | cut -c1-7) - docker build -f ./docker/Dockerfile.web -t mostovik-web:${BRANCH_TAG} -t mostovik-web:${BRANCH_TAG}-${SHA_SHORT} . - - - name: Build celery image - run: | - BRANCH_TAG=$(echo ${GITHUB_REF_NAME} | sed 's/\//-/g') - SHA_SHORT=$(echo ${GITHUB_SHA} | cut -c1-7) - docker build -f ./docker/Dockerfile.celery -t mostovik-celery:${BRANCH_TAG} -t mostovik-celery:${BRANCH_TAG}-${SHA_SHORT} . - - push: - name: Push to Gitea Registry - runs-on: ubuntu-latest - needs: [build] - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/dev' - - steps: - - name: Checkout code - run: | - REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") - git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . - git checkout ${GITHUB_SHA} + REPO_URL=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch="${GITHUB_REF_NAME}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . + git checkout "${GITHUB_SHA}" - name: Build and push images - run: | - # Install crane for pushing to Gitea container registry - curl -sL https://github.com/google/go-containerregistry/releases/download/v0.19.0/go-containerregistry_Linux_x86_64.tar.gz | tar xz crane - chmod +x crane - - BRANCH_TAG=$(echo ${GITHUB_REF_NAME} | sed 's/\//-/g') - SHA_SHORT=$(echo ${GITHUB_SHA} | cut -c1-7) - REGISTRY_HOST="10.10.0.10:3000" - REGISTRY="${REGISTRY_HOST}/${{ github.repository_owner }}" - - # Debug - echo "Registry: ${REGISTRY_HOST}" - echo "Actor: ${GITHUB_ACTOR}" - - # Login to Gitea container registry (HTTP, requires --insecure) - echo "${REGISTRY_PASSWORD}" | ./crane auth login --insecure ${REGISTRY_HOST} -u "${REGISTRY_USER}" --password-stdin - - # Build and push web image - docker build -f ./docker/Dockerfile.web -t mostovik-web:local . - docker save mostovik-web:local -o /tmp/web.tar - - ./crane push --insecure /tmp/web.tar ${REGISTRY}/mostovik-web:${BRANCH_TAG} - ./crane push --insecure /tmp/web.tar ${REGISTRY}/mostovik-web:${BRANCH_TAG}-${SHA_SHORT} - - if [ "${GITHUB_REF_NAME}" = "main" ]; then - ./crane push --insecure /tmp/web.tar ${REGISTRY}/mostovik-web:latest - fi - - # Build and push celery image - docker build -f ./docker/Dockerfile.celery -t mostovik-celery:local . - docker save mostovik-celery:local -o /tmp/celery.tar - - ./crane push --insecure /tmp/celery.tar ${REGISTRY}/mostovik-celery:${BRANCH_TAG} - ./crane push --insecure /tmp/celery.tar ${REGISTRY}/mostovik-celery:${BRANCH_TAG}-${SHA_SHORT} - - if [ "${GITHUB_REF_NAME}" = "main" ]; then - ./crane push --insecure /tmp/celery.tar ${REGISTRY}/mostovik-celery:latest - fi env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + curl -sL https://github.com/google/go-containerregistry/releases/download/v0.19.0/go-containerregistry_Linux_x86_64.tar.gz | tar xz crane + chmod +x crane + + BRANCH_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/\//-/g') + SHA_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7) + REPO_OWNER="${GITHUB_REPOSITORY%%/*}" + REGISTRY="${REGISTRY_HOST}/${REPO_OWNER}" + + echo "Registry: ${REGISTRY_HOST}" + echo "Actor: ${GITHUB_ACTOR}" + + echo "${REGISTRY_PASSWORD}" | ./crane auth login --insecure "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin + + docker build -f ./docker/Dockerfile.web -t "${WEB_IMAGE}:local" . + docker save "${WEB_IMAGE}:local" -o /tmp/web.tar + + ./crane push --insecure /tmp/web.tar "${REGISTRY}/${WEB_IMAGE}:${BRANCH_TAG}" + ./crane push --insecure /tmp/web.tar "${REGISTRY}/${WEB_IMAGE}:${BRANCH_TAG}-${SHA_SHORT}" + if [ "${GITHUB_REF_NAME}" = "main" ]; then + ./crane push --insecure /tmp/web.tar "${REGISTRY}/${WEB_IMAGE}:latest" + fi + + docker build -f ./docker/Dockerfile.celery -t "${CELERY_IMAGE}:local" . + docker save "${CELERY_IMAGE}:local" -o /tmp/celery.tar + + ./crane push --insecure /tmp/celery.tar "${REGISTRY}/${CELERY_IMAGE}:${BRANCH_TAG}" + ./crane push --insecure /tmp/celery.tar "${REGISTRY}/${CELERY_IMAGE}:${BRANCH_TAG}-${SHA_SHORT}" + if [ "${GITHUB_REF_NAME}" = "main" ]; then + ./crane push --insecure /tmp/celery.tar "${REGISTRY}/${CELERY_IMAGE}:latest" + fi - name: Image summary run: | - echo "Images pushed to 10.10.0.10:3000/${{ github.repository_owner }}/" + REPO_OWNER="${GITHUB_REPOSITORY%%/*}" + echo "Images pushed to ${REGISTRY_HOST}/${REPO_OWNER}/" - deploy: - name: Deploy to Server + deploy_dev: + name: Deploy (dev) runs-on: ubuntu-latest - needs: [push] - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/dev' + needs: [build_push] + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + concurrency: deploy-dev + environment: dev steps: - name: Checkout code run: | - REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") - git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . - git checkout ${GITHUB_SHA} + REPO_URL=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch="${GITHUB_REF_NAME}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . + git checkout "${GITHUB_SHA}" - name: Deploy via SSH - run: | - BRANCH_TAG=$(echo ${GITHUB_REF_NAME} | sed 's/\//-/g') - - # Setup SSH (decode base64 key) - mkdir -p ~/.ssh - echo "${DEPLOY_SSH_KEY}" | base64 -d > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - ssh-keyscan -H ${DEPLOY_HOST} >> ~/.ssh/known_hosts 2>/dev/null - - # Copy docker-compose.prod.yml to server - scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no docker-compose.prod.yml ${DEPLOY_USER}@${DEPLOY_HOST}:/opt/mostovik-backend/ - - # Deploy commands - ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} " - cd /opt/mostovik-backend - - # Login to registry (HTTP on internal IP) - echo '${REGISTRY_PASSWORD}' | docker login --username '${REGISTRY_USER}' --password-stdin 10.10.0.10:3000 - - # Pull new images - export IMAGE_TAG=${BRANCH_TAG} - docker compose -f docker-compose.prod.yml pull web celery_worker celery_beat - - # Stop and remove all project containers - docker compose -f docker-compose.prod.yml down --remove-orphans || true - docker rm -f mostovik_db mostovik_redis mostovik_web mostovik_celery_worker mostovik_celery_beat 2>/dev/null || true - - # Start services - docker compose -f docker-compose.prod.yml up -d - - # Cleanup old images - docker image prune -f - " - - echo "Deployed ${BRANCH_TAG} to ${DEPLOY_HOST}" env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + BRANCH_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/\//-/g') + + mkdir -p ~/.ssh + echo "${DEPLOY_SSH_KEY}" | base64 -d > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "${DEPLOY_HOST}" >> ~/.ssh/known_hosts 2>/dev/null + + scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no docker-compose.prod.yml "${DEPLOY_USER}@${DEPLOY_HOST}:/opt/mostovik-backend/" + + ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" " + set -e + cd /opt/mostovik-backend + echo '${REGISTRY_PASSWORD}' | docker login --username '${REGISTRY_USER}' --password-stdin ${REGISTRY_HOST} + export IMAGE_TAG=${BRANCH_TAG} + docker compose -f docker-compose.prod.yml pull web celery_worker celery_beat + docker compose -f docker-compose.prod.yml down --remove-orphans || true + docker rm -f mostovik_db mostovik_redis mostovik_web mostovik_celery_worker mostovik_celery_beat 2>/dev/null || true + docker compose -f docker-compose.prod.yml up -d + docker image prune -f + " + + echo "Deployed ${BRANCH_TAG} to ${DEPLOY_HOST}" + + deploy_prod: + name: Deploy (prod) + runs-on: ubuntu-latest + needs: [build_push] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + concurrency: deploy-prod + environment: prod + + steps: + - name: Checkout code + run: | + REPO_URL=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch="${GITHUB_REF_NAME}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . + git checkout "${GITHUB_SHA}" + + - name: Deploy via SSH + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + BRANCH_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/\//-/g') + + mkdir -p ~/.ssh + echo "${DEPLOY_SSH_KEY}" | base64 -d > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "${DEPLOY_HOST}" >> ~/.ssh/known_hosts 2>/dev/null + + scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no docker-compose.prod.yml "${DEPLOY_USER}@${DEPLOY_HOST}:/opt/mostovik-backend/" + + ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" " + set -e + cd /opt/mostovik-backend + echo '${REGISTRY_PASSWORD}' | docker login --username '${REGISTRY_USER}' --password-stdin ${REGISTRY_HOST} + export IMAGE_TAG=${BRANCH_TAG} + docker compose -f docker-compose.prod.yml pull web celery_worker celery_beat + docker compose -f docker-compose.prod.yml down --remove-orphans || true + docker rm -f mostovik_db mostovik_redis mostovik_web mostovik_celery_worker mostovik_celery_beat 2>/dev/null || true + docker compose -f docker-compose.prod.yml up -d + docker image prune -f + " + + echo "Deployed ${BRANCH_TAG} to ${DEPLOY_HOST}" diff --git a/CI_CD_SUMMARY.md b/CI_CD_SUMMARY.md new file mode 100644 index 0000000..64d9abe --- /dev/null +++ b/CI_CD_SUMMARY.md @@ -0,0 +1,52 @@ +# CI/CD Summary (Gitea Actions) + +This project uses Gitea Actions for CI/CD, Gitea Registry for images, and SSH + Docker Compose for deploy. + +**Branch Rules** +1. `main` is production releases. Push to `main` builds, pushes images, and deploys to prod. +2. `dev` deploys to the dev stand (host `10.10.0.112`). Push to `dev` builds, pushes images, and deploys to dev. +3. `feature/*` runs CI only. No build, no push, no deploy. +4. Pull requests to `main` and `dev` run CI only. + +**Triggers** +1. `push` to `main`, `dev`, `feature/**`. +2. `pull_request` to `main` and `dev`. + +**Pipeline Jobs** +1. `lint` +2. `test` +3. `build_push` for `main` and `dev` only. +4. `deploy_dev` for `dev` only. +5. `deploy_prod` for `main` only. + +**Python Tooling** +1. Python 3.11. +2. `uv` for dependency sync. +3. Ruff lint and format checks for `src` and `tests`. + +**Image Tags** +1. `${branch}` and `${branch}-${sha7}` for every push to `main` or `dev`. +2. `latest` for `main` only. + +**Registry** +1. Host: `10.10.0.10:3000` (HTTP, insecure). +2. Namespace: ``. +3. Images: `mostovik-web`, `mostovik-celery`. +4. Push uses `crane` with `--insecure`. + +**Deploy** +1. `docker-compose.prod.yml` is copied to `/opt/mostovik-backend/` on the target host. +2. `IMAGE_TAG` is set to the branch name. +3. Docker Compose pulls `web`, `celery_worker`, `celery_beat`, then restarts the stack. +4. Old images are pruned at the end. + +**Secrets** +1. `REGISTRY_USER` +2. `REGISTRY_TOKEN` +3. `DEPLOY_HOST` +4. `DEPLOY_USER` +5. `DEPLOY_SSH_KEY` (base64-encoded private key) + +**Environment-Specific Secrets** +1. Use Gitea environments `dev` and `prod` with the same secret names above. +2. If environment secrets are not available, set repo-level secrets to the correct target before deploying. diff --git a/pyproject.toml b/pyproject.toml index 70fb069..9575abd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ packages = ["src"] # ================================================================================== [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "config.settings.test" -python_paths = ["src"] +django_find_project = false testpaths = ["tests"] addopts = [ "--verbose", diff --git a/sitecustomize.py b/sitecustomize.py new file mode 100644 index 0000000..53a550d --- /dev/null +++ b/sitecustomize.py @@ -0,0 +1,10 @@ +"""Ensure src/ is on sys.path for tooling like pytest-django.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +SRC = ROOT / "src" + +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) diff --git a/src/apps/core/management/commands/base.py b/src/apps/core/management/commands/base.py index ed775a4..87323ea 100644 --- a/src/apps/core/management/commands/base.py +++ b/src/apps/core/management/commands/base.py @@ -56,6 +56,7 @@ class BaseAppCommand(BaseCommand): requires_migrations_checks = True requires_system_checks = "__all__" use_transaction = False # Обернуть в транзакцию + input_func = staticmethod(input) def add_arguments(self, parser) -> None: """Добавление базовых аргументов.""" @@ -240,7 +241,8 @@ class BaseAppCommand(BaseCommand): return True self.stdout.write(f"\n{message} [y/N]: ", ending="") - response = input().strip().lower() + input_func = getattr(self, "input_func", input) + response = input_func().strip().lower() return response in ("y", "yes", "да", "д") def abort(self, message: str) -> None: diff --git a/src/apps/core/serializers.py b/src/apps/core/serializers.py index e4a13c9..fbc8300 100644 --- a/src/apps/core/serializers.py +++ b/src/apps/core/serializers.py @@ -26,7 +26,7 @@ class BackgroundJobSerializer(serializers.Serializer): started_at = serializers.DateTimeField(read_only=True) completed_at = serializers.DateTimeField(read_only=True) created_at = serializers.DateTimeField(read_only=True) - duration = serializers.FloatField(read_only=True, source="duration") + duration = serializers.FloatField(read_only=True) # Вычисляемые поля is_finished = serializers.BooleanField(read_only=True) diff --git a/src/apps/core/services.py b/src/apps/core/services.py index 48aeedf..1eb45b0 100644 --- a/src/apps/core/services.py +++ b/src/apps/core/services.py @@ -107,7 +107,10 @@ class BaseService(Generic[M]): """ for field, value in kwargs.items(): setattr(instance, field, value) - instance.save(update_fields=list(kwargs.keys())) + update_fields = set(kwargs.keys()) + if hasattr(instance, "updated_at"): + update_fields.add("updated_at") + instance.save(update_fields=list(update_fields)) return instance @classmethod diff --git a/src/apps/core/tasks.py b/src/apps/core/tasks.py index 2a43479..faedaa9 100644 --- a/src/apps/core/tasks.py +++ b/src/apps/core/tasks.py @@ -57,8 +57,8 @@ class BaseTask(Task): extra={ "task_id": task_id, "task_name": self.name, - "args": str(args)[:200], - "kwargs": str(kwargs)[:200], + "task_args": str(args)[:200], + "task_kwargs": str(kwargs)[:200], }, ) @@ -94,8 +94,8 @@ class BaseTask(Task): "task_id": task_id, "task_name": self.name, "exception": str(exc), - "args": str(args)[:200], - "kwargs": str(kwargs)[:200], + "task_args": str(args)[:200], + "task_kwargs": str(kwargs)[:200], }, exc_info=True, ) diff --git a/src/apps/parsers/clients/base.py b/src/apps/parsers/clients/base.py index 7717f97..a9e2cc3 100644 --- a/src/apps/parsers/clients/base.py +++ b/src/apps/parsers/clients/base.py @@ -10,6 +10,7 @@ from dataclasses import dataclass, field from typing import Any import requests +from requests.adapters import BaseAdapter logger = logging.getLogger(__name__) @@ -68,6 +69,7 @@ class BaseHTTPClient: proxies: list[str] | None = None timeout: int = 30 headers: dict[str, str] = field(default_factory=dict) + adapter: BaseAdapter | None = None def __post_init__(self) -> None: """Инициализация после создания dataclass.""" @@ -115,6 +117,17 @@ class BaseHTTPClient: default_headers.update(self.headers) session.headers.update(default_headers) + if self.adapter is not None: + session.mount(self.base_url, self.adapter) + if self.base_url.startswith("http://"): + session.mount( + self.base_url.replace("http://", "https://", 1), self.adapter + ) + elif self.base_url.startswith("https://"): + session.mount( + self.base_url.replace("https://", "http://", 1), self.adapter + ) + return session def rotate_proxy(self) -> str | None: diff --git a/src/apps/parsers/clients/checko/client.py b/src/apps/parsers/clients/checko/client.py index 175ce6a..05a0397 100644 --- a/src/apps/parsers/clients/checko/client.py +++ b/src/apps/parsers/clients/checko/client.py @@ -96,6 +96,7 @@ from apps.parsers.clients.checko.schemas.responses import ( TaxDebt, TaxPenalty, ) +from requests.adapters import BaseAdapter logger = logging.getLogger(__name__) @@ -274,6 +275,9 @@ class CheckoClient: proxies: list[str] | None = None """Список прокси (опционально).""" + http_adapter: BaseAdapter | None = None + """Опциональный HTTP адаптер (для тестов).""" + _http_client: BaseHTTPClient = field(init=False, repr=False) def __post_init__(self) -> None: @@ -282,6 +286,7 @@ class CheckoClient: base_url=self.base_url, proxies=self.proxies, timeout=self.timeout, + adapter=self.http_adapter, # Don't request Brotli compression (br) as it requires extra dependency headers={"Accept-Encoding": "gzip, deflate"}, ) diff --git a/src/apps/parsers/clients/fns/parser.py b/src/apps/parsers/clients/fns/parser.py index 90cca8b..4186b4b 100644 --- a/src/apps/parsers/clients/fns/parser.py +++ b/src/apps/parsers/clients/fns/parser.py @@ -195,7 +195,7 @@ class FNSExcelParser: """Преобразует значение ячейки в int или None.""" if value is None: return None - if isinstance(value, int | float): + if isinstance(value, (int, float)): return int(value) if isinstance(value, str): value = value.strip() diff --git a/src/apps/parsers/clients/minpromtorg/industrial.py b/src/apps/parsers/clients/minpromtorg/industrial.py index 6f6abdf..aa7924a 100644 --- a/src/apps/parsers/clients/minpromtorg/industrial.py +++ b/src/apps/parsers/clients/minpromtorg/industrial.py @@ -13,6 +13,7 @@ from io import BytesIO from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate from openpyxl import load_workbook +from requests.adapters import BaseAdapter logger = logging.getLogger(__name__) @@ -51,10 +52,12 @@ class IndustrialProductionClient: proxies: list[str] | None = None host: str = DEFAULT_HOST + scheme: str = "https" api_path: str = DEFAULT_API_PATH doc_type: str = DEFAULT_DOC_TYPE query: str = DEFAULT_QUERY timeout: int = 120 + http_adapter: BaseAdapter | None = None _http_client: BaseHTTPClient | None = field(default=None, repr=False) def __post_init__(self) -> None: @@ -66,9 +69,10 @@ class IndustrialProductionClient: """Ленивая инициализация HTTP клиента.""" if self._http_client is None: self._http_client = BaseHTTPClient( - base_url=f"https://{self.host}", + base_url=f"{self.scheme}://{self.host}", proxies=self.proxies, timeout=self.timeout, + adapter=self.http_adapter, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0", "Accept": "application/json", @@ -162,7 +166,7 @@ class IndustrialProductionClient: ) # URL может быть относительным if url and not url.startswith("http"): - return f"https://{self.host}{url}" + return f"{self.scheme}://{self.host}{url}" return url return None diff --git a/src/apps/parsers/clients/minpromtorg/manufactures.py b/src/apps/parsers/clients/minpromtorg/manufactures.py index 026fba8..0efdfb7 100644 --- a/src/apps/parsers/clients/minpromtorg/manufactures.py +++ b/src/apps/parsers/clients/minpromtorg/manufactures.py @@ -13,6 +13,7 @@ from io import BytesIO from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError from apps.parsers.clients.minpromtorg.schemas import Manufacturer from openpyxl import load_workbook +from requests.adapters import BaseAdapter logger = logging.getLogger(__name__) @@ -51,10 +52,12 @@ class ManufacturesClient: proxies: list[str] | None = None host: str = DEFAULT_HOST + scheme: str = "https" api_path: str = DEFAULT_API_PATH doc_type: str = DEFAULT_DOC_TYPE query: str = DEFAULT_QUERY timeout: int = 120 + http_adapter: BaseAdapter | None = None _http_client: BaseHTTPClient | None = field(default=None, repr=False) def __post_init__(self) -> None: @@ -66,9 +69,10 @@ class ManufacturesClient: """Ленивая инициализация HTTP клиента.""" if self._http_client is None: self._http_client = BaseHTTPClient( - base_url=f"https://{self.host}", + base_url=f"{self.scheme}://{self.host}", proxies=self.proxies, timeout=self.timeout, + adapter=self.http_adapter, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0", "Accept": "application/json", @@ -159,7 +163,7 @@ class ManufacturesClient: "Latest file: %s (date: %s)", latest_file.get("name"), latest_date ) if url and not url.startswith("http"): - return f"https://{self.host}{url}" + return f"{self.scheme}://{self.host}{url}" return url return None diff --git a/src/apps/parsers/clients/proverki/client.py b/src/apps/parsers/clients/proverki/client.py index f7f75f6..8738feb 100644 --- a/src/apps/parsers/clients/proverki/client.py +++ b/src/apps/parsers/clients/proverki/client.py @@ -21,6 +21,7 @@ from xml.etree import ( # noqa: S314 - XML parsing with proper error handling from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError from apps.parsers.clients.proverki.schemas import Inspection, InspectionPlan +from requests.adapters import BaseAdapter logger = logging.getLogger(__name__) @@ -65,9 +66,12 @@ class ProverkiClient: proxies: list[str] | None = None host: str = DEFAULT_HOST + scheme: str = "https" timeout: int = 120 temp_dir: str | None = None use_playwright: bool = True # Использовать Playwright как fallback + http_adapter: BaseAdapter | None = None + STREAMING_THRESHOLD_BYTES = 50 * 1024 * 1024 _http_client: BaseHTTPClient | None = field(default=None, repr=False) _playwright: object | None = field(default=None, repr=False) _browser: object | None = field(default=None, repr=False) @@ -84,9 +88,10 @@ class ProverkiClient: """Ленивая инициализация HTTP клиента.""" if self._http_client is None: self._http_client = BaseHTTPClient( - base_url=f"https://{self.host}", + base_url=f"{self.scheme}://{self.host}", proxies=self.proxies, timeout=self.timeout, + adapter=self.http_adapter, headers={ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " @@ -243,18 +248,25 @@ class ProverkiClient: """Скачать файл и распарсить его содержимое.""" logger.info("Downloading: %s (format=%s)", file_url, file_format) - # Если это портал - сразу используем Playwright + # Если это портал - используем Playwright или делаем прямую попытку скачивания if file_format == "portal" or "/portal/" in file_url: - if not self.use_playwright: - raise ProverkiClientError( - "Портал proverki.gov.ru требует JavaScript. " - "Включите use_playwright=True.", - url=file_url, - ) - if progress_callback: - progress_callback(20, "Навигация по порталу...") - content = self._download_from_portal(file_url, progress_callback) - self._close_playwright() + if self.use_playwright: + if progress_callback: + progress_callback(20, "Навигация по порталу...") + content = self._download_from_portal(file_url, progress_callback) + self._close_playwright() + else: + if progress_callback: + progress_callback(20, f"Скачивание {file_url}...") + content = self.http_client.download_file(file_url) + logger.info("Downloaded %d bytes", len(content)) + if content[:15].lower().startswith((b" 50 * 1024 * 1024: # > 50 MB + if len(content) > self.STREAMING_THRESHOLD_BYTES: logger.info( "Large file detected (%d MB), using streaming parser", len(content) // (1024 * 1024), diff --git a/src/apps/parsers/clients/zakupki/__init__.py b/src/apps/parsers/clients/zakupki/__init__.py index 98da392..15b3735 100644 --- a/src/apps/parsers/clients/zakupki/__init__.py +++ b/src/apps/parsers/clients/zakupki/__init__.py @@ -26,6 +26,7 @@ from xml.etree import ( # noqa: S314 - XML parsing with proper error handling from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError from apps.parsers.clients.zakupki.schemas import Procurement, ProcurementPlan +from requests.adapters import BaseAdapter logger = logging.getLogger(__name__) @@ -85,7 +86,10 @@ class ZakupkiClient: token: str | None = None # Токен для SOAP API (обязателен для работы) proxies: list[str] | None = None host: str = DEFAULT_HOST + scheme: str = "https" + soap_url: str = SOAP_API_URL timeout: int = 120 + http_adapter: BaseAdapter | None = None _http_client: BaseHTTPClient | None = field(default=None, repr=False) def __post_init__(self) -> None: @@ -97,9 +101,10 @@ class ZakupkiClient: """Ленивая инициализация HTTP клиента.""" if self._http_client is None: self._http_client = BaseHTTPClient( - base_url=f"https://{self.host}", + base_url=f"{self.scheme}://{self.host}", proxies=self.proxies, timeout=self.timeout, + adapter=self.http_adapter, headers={ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " @@ -233,7 +238,7 @@ class ZakupkiClient: # Отправляем SOAP запрос try: response = self.http_client.post( - SOAP_API_URL, + self.soap_url, data=soap_request.encode("utf-8"), headers={ "Content-Type": "text/xml; charset=utf-8", @@ -479,13 +484,13 @@ class ZakupkiClient: if month: file_name = f"notifications_{region_code}_{year}{month:02d}_{fz_suffix}.zip" file_url = ( - f"https://{self.host}/opendata/download/" + f"{self.scheme}://{self.host}/opendata/download/" f"notifications/{region_code}/{year}/{month:02d}/{fz_suffix}.zip" ) else: file_name = f"notifications_{region_code}_{year}_{fz_suffix}.zip" file_url = ( - f"https://{self.host}/opendata/download/" + f"{self.scheme}://{self.host}/opendata/download/" f"notifications/{region_code}/{year}/{fz_suffix}.zip" ) diff --git a/src/apps/parsers/migrations/0008_add_load_log_unique_constraint.py b/src/apps/parsers/migrations/0008_add_load_log_unique_constraint.py new file mode 100644 index 0000000..9d97a73 --- /dev/null +++ b/src/apps/parsers/migrations/0008_add_load_log_unique_constraint.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.25 on 2026-02-05 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("parsers", "0007_add_fns_models"), + ] + + operations = [ + migrations.AddConstraint( + model_name="parserloadlog", + constraint=models.UniqueConstraint( + fields=("source", "batch_id"), + name="unique_load_batch_per_source", + ), + ), + ] diff --git a/src/apps/parsers/models.py b/src/apps/parsers/models.py index 2448f7d..6c42b3b 100644 --- a/src/apps/parsers/models.py +++ b/src/apps/parsers/models.py @@ -60,6 +60,12 @@ class ParserLoadLog(TimestampMixin, models.Model): indexes = [ models.Index(fields=["source", "batch_id"]), ] + constraints = [ + models.UniqueConstraint( + fields=["source", "batch_id"], + name="unique_load_batch_per_source", + ), + ] def __str__(self) -> str: return f"Load #{self.batch_id} ({self.source}) - {self.records_count} records" diff --git a/src/apps/parsers/services.py b/src/apps/parsers/services.py index 51b4a4a..0c3ff04 100644 --- a/src/apps/parsers/services.py +++ b/src/apps/parsers/services.py @@ -20,7 +20,7 @@ from apps.parsers.models import ( ProcurementRecord, Proxy, ) -from django.db import transaction +from django.db import IntegrityError, transaction from django.utils import timezone logger = logging.getLogger(__name__) @@ -39,6 +39,7 @@ class ParserLoadLogService(BaseService[ParserLoadLog]): model = ParserLoadLog @classmethod + @transaction.atomic def get_next_batch_id(cls, source: str) -> int: """ Получить следующий batch_id для источника. @@ -49,7 +50,12 @@ class ParserLoadLogService(BaseService[ParserLoadLog]): Returns: Следующий batch_id """ - last_log = cls.model.objects.filter(source=source).order_by("-batch_id").first() + last_log = ( + cls.model.objects.select_for_update() + .filter(source=source) + .order_by("-batch_id") + .first() + ) return (last_log.batch_id + 1) if last_log else 1 @classmethod @@ -84,6 +90,37 @@ class ParserLoadLogService(BaseService[ParserLoadLog]): error_message=error_message, ) + @classmethod + @transaction.atomic + def create_load_log_with_next_batch_id( + cls, + *, + source: str, + records_count: int = 0, + status: str = "success", + error_message: str = "", + max_retries: int = 5, + ) -> tuple[ParserLoadLog, int]: + """ + Создать запись лога с атомарной генерацией batch_id. + + Возвращает (log, batch_id). + """ + for _ in range(max_retries): + batch_id = cls.get_next_batch_id(source) + try: + log = cls.create_load_log( + source=source, + batch_id=batch_id, + records_count=records_count, + status=status, + error_message=error_message, + ) + return log, batch_id + except IntegrityError: + continue + raise RuntimeError("Failed to allocate unique batch_id") + @classmethod def mark_failed(cls, log: ParserLoadLog, error_message: str) -> ParserLoadLog: """Отметить загрузку как неудачную.""" diff --git a/src/apps/parsers/tests/test_checko_e2e.py b/src/apps/parsers/tests/test_checko_e2e.py index 7f40e2f..0b7dad1 100644 --- a/src/apps/parsers/tests/test_checko_e2e.py +++ b/src/apps/parsers/tests/test_checko_e2e.py @@ -1,438 +1,561 @@ """ E2E тесты для Checko API клиента. -Тесты с реальными HTTP запросами к api.checko.ru. - -Для запуска E2E тестов с реальными данными: - RUN_E2E_TESTS=1 uv run python manage.py test apps.parsers.tests.test_checko_e2e - -Тесты пропускаются по умолчанию, чтобы не нагружать API -и не тратить баланс в обычных тестовых прогонах. +Тесты выполняются через локальный HTTP сервер (без внешних API). """ -import os -import unittest +from __future__ import annotations +import json +from urllib.parse import parse_qs + +from apps.parsers.clients.checko import ( + CheckoAPIError, + CheckoClient, + CheckoNotFoundError, + CompanyRequest, + ContractLaw, + ContractsRequest, + EnforcementsRequest, + FinancesRequest, + InspectionsRequest, + LegalCasesRequest, + ObjectType, + SearchRequest, + SearchType, +) +from apps.parsers.clients.checko.datasets import ( + OKFS, + OKOPF, + OKVED2, + AccountCodes, +) from django.test import TestCase -# Флаг для запуска E2E тестов -RUN_E2E_TESTS = os.environ.get("RUN_E2E_TESTS", "").lower() in ("1", "true", "yes") - -# API ключ для тестов (из переменной окружения) -CHECKO_API_KEY = os.environ.get("CHECKO_API_KEY", "") - -# Тестовый ИНН: ПАО "Ростелеком" -TEST_INN = "7707049388" -TEST_OGRN = "1027700198767" +from tests.utils import Response, TestHTTPServer +from tests.utils.fixtures import fake + + +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +def _meta_ok() -> dict: + return { + "status": "ok", + "today_request_count": fake.random_int(min=1, max=10), + "balance": float(fake.pydecimal(left_digits=2, right_digits=2, positive=True)), + } + + +def _client_for(server: TestHTTPServer, api_key: str = "test_key") -> CheckoClient: + return CheckoClient( + api_key=api_key, + base_url=f"{server.base_url}/v2", + http_adapter=server.adapter, + ) + + +def _json_response(payload: dict) -> Response: + return Response( + status=200, + body=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json; charset=utf-8"}, + ) -@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") class CheckoClientE2ETestCase(TestCase): - """ - E2E тесты клиента CheckoClient. - - Выполняют реальные HTTP запросы к api.checko.ru. - """ - - @classmethod - def setUpClass(cls): - """Подготовка класса.""" - super().setUpClass() - from apps.parsers.clients.checko import CheckoClient - - cls.client = CheckoClient(api_key=CHECKO_API_KEY, timeout=60) - - @classmethod - def tearDownClass(cls): - """Очистка класса.""" - cls.client.close() - super().tearDownClass() + """E2E тесты клиента CheckoClient через локальный HTTP сервер.""" def test_get_company_by_inn(self): - """Получение информации о компании по ИНН.""" - from apps.parsers.clients.checko import CompanyRequest + inn = _digits(10) + ogrn = _digits(13) + short_name = fake.company() - response = self.client.get_company(CompanyRequest(inn=TEST_INN)) + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + { + "data": { + "ogrn": ogrn, + "inn": inn, + "short_name": short_name, + "full_name": fake.company(), + "status": {"code": "100", "name": "Действующее"}, + "legal_address": { + "full_address": fake.address().replace("\n", ", "), + "postal_code": _digits(6), + }, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_company(CompanyRequest(inn=inn)) - # Проверка мета-информации self.assertEqual(response.meta.status, "ok") - self.assertIsNotNone(response.meta.balance) - self.assertGreaterEqual(response.meta.today_request_count, 1) - - # Проверка данных компании - self.assertIsNotNone(response.data) - self.assertEqual(response.data.inn, TEST_INN) - self.assertEqual(response.data.ogrn, TEST_OGRN) - self.assertIsNotNone(response.data.full_name) - self.assertIn("РОСТЕЛЕКОМ", response.data.full_name.upper()) - - # Проверка статуса - self.assertIsNotNone(response.data.status) - self.assertFalse(response.data.status.restricted_access) - - # Проверка адреса + self.assertEqual(response.data.inn, inn) + self.assertEqual(response.data.ogrn, ogrn) + self.assertEqual(response.data.short_name, short_name) self.assertIsNotNone(response.data.legal_address) - # Вывод для отладки - print(f"\n[E2E] Company: {response.data.short_name}") - print(f"[E2E] INN: {response.data.inn}, OGRN: {response.data.ogrn}") - print(f"[E2E] Status: {response.data.status.name}") - print(f"[E2E] Balance: {response.meta.balance}") - def test_get_company_by_ogrn(self): - """Получение информации о компании по ОГРН.""" - from apps.parsers.clients.checko import CompanyRequest + inn = _digits(10) + ogrn = _digits(13) - response = self.client.get_company(CompanyRequest(ogrn=TEST_OGRN)) + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + { + "data": { + "ogrn": ogrn, + "inn": inn, + "short_name": fake.company(), + "status": {"code": "100", "name": "Действующее"}, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_company(CompanyRequest(ogrn=ogrn)) self.assertEqual(response.meta.status, "ok") - self.assertIsNotNone(response.data) - self.assertEqual(response.data.ogrn, TEST_OGRN) - self.assertEqual(response.data.inn, TEST_INN) + self.assertEqual(response.data.ogrn, ogrn) + self.assertEqual(response.data.inn, inn) def test_get_company_with_source(self): - """Получение данных компании с исходными данными ЕГРЮЛ.""" - from apps.parsers.clients.checko import CompanyRequest + inn = _digits(10) + source_data = {"raw": fake.text(max_nb_chars=50)} - response = self.client.get_company(CompanyRequest(inn=TEST_INN, source=True)) + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + { + "data": { + "inn": inn, + "ogrn": _digits(13), + "short_name": fake.company(), + "status": {"code": "100", "name": "Действующее"}, + }, + "meta": _meta_ok(), + "source_data": source_data, + }, + ) + client = _client_for(server) + response = client.get_company(CompanyRequest(inn=inn, source=True)) self.assertEqual(response.meta.status, "ok") - # source_data может быть None или dict - print(f"\n[E2E] Source data present: {response.source_data is not None}") + self.assertIsNotNone(response.source_data) def test_search_by_name(self): - """Поиск организаций по наименованию.""" - from apps.parsers.clients.checko import ( - ObjectType, - SearchRequest, - SearchType, - ) + inn = _digits(10) + ogrn = _digits(13) + name = fake.company() - response = self.client.search( - SearchRequest( - by=SearchType.NAME, - obj=ObjectType.ORGANIZATION, - query="Ростелеком", - limit=10, + with TestHTTPServer() as server: + server.add_json( + "/v2/search", + { + "data": { + "records": [ + {"inn": inn, "ogrn": ogrn, "short_name": name}, + ], + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.search( + SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query=f"{name} {fake.word()}", + limit=10, + ) ) - ) self.assertEqual(response.meta.status, "ok") self.assertIsNotNone(response.data) self.assertGreater(len(response.data.organizations), 0) - # Должен найти ПАО Ростелеком - inns = [org.inn for org in response.data.organizations] - print(f"\n[E2E] Search 'Ростелеком': found {len(inns)} organizations") - print(f"[E2E] First 5 INNs: {inns[:5]}") - def test_search_active_only(self): - """Поиск только активных организаций.""" - from apps.parsers.clients.checko import ( - ObjectType, - SearchRequest, - SearchType, - ) + inn = _digits(10) - response = self.client.search( - SearchRequest( - by=SearchType.NAME, - obj=ObjectType.ORGANIZATION, - query="Ростелеком", - active=True, - limit=5, + with TestHTTPServer() as server: + server.add_json( + "/v2/search", + { + "data": { + "records": [ + { + "inn": inn, + "status": {"code": "100", "name": "Действующее"}, + }, + ], + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.search( + SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query=fake.company(), + active=True, + limit=5, + ) ) - ) self.assertEqual(response.meta.status, "ok") self.assertIsNotNone(response.data) - # Все найденные должны быть активными - for org in response.data.organizations: - if org.status: - # Статусы активных: "Действующее", код 100 и т.д. - print(f"[E2E] Org {org.inn}: status={org.status}") - def test_get_finances(self): - """Получение финансовой отчетности.""" - from apps.parsers.clients.checko import FinancesRequest + inn = _digits(10) + ogrn = _digits(13) - response = self.client.get_finances(FinancesRequest(inn=TEST_INN)) + with TestHTTPServer() as server: + server.add_json( + "/v2/finances", + { + "data": { + "inn": inn, + "ogrn": ogrn, + "reports": [ + { + "year": fake.random_int(min=2020, max=2025), + "period": 12, + "lines": [ + { + "code": _digits(4), + "value": fake.random_int(10, 9999), + } + ], + } + ], + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_finances(FinancesRequest(inn=inn)) self.assertEqual(response.meta.status, "ok") self.assertIsNotNone(response.data) - self.assertEqual(response.data.inn, TEST_INN) - - # Должны быть финансовые отчёты - if response.data.reports: - print(f"\n[E2E] Found {len(response.data.reports)} financial reports") - for report in response.data.reports[:3]: - print(f"[E2E] Year {report.year}: balance lines={len(report.balance)}") - - # Сводные показатели - if response.data.summary: - print(f"[E2E] Summary: revenue={response.data.summary.revenue}") + self.assertEqual(response.data.inn, inn) def test_get_contracts_fz44(self): - """Получение контрактов по 44-ФЗ.""" - from apps.parsers.clients.checko import ContractLaw, ContractsRequest + inn = _digits(10) + registry_number = _digits(12) - response = self.client.get_contracts( - ContractsRequest( - inn=TEST_INN, - law=ContractLaw.FZ44, - limit=10, + with TestHTTPServer() as server: + server.add_json( + "/v2/contracts", + { + "data": { + "contracts": [ + {"registry_number": registry_number, "law": "44-FZ"}, + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_contracts( + ContractsRequest(inn=inn, law=ContractLaw.FZ44, limit=10) ) - ) self.assertEqual(response.meta.status, "ok") self.assertIsNotNone(response.data) - - print(f"\n[E2E] Found {len(response.data.contracts)} contracts (44-ФЗ)") - if response.data.pagination: - print(f"[E2E] Total records: {response.data.pagination.total_records}") - if response.data.total_sum: - print(f"[E2E] Total sum: {response.data.total_sum:,} руб.") - - # Проверка первого контракта - if response.data.contracts: - contract = response.data.contracts[0] - self.assertIsNotNone(contract.registry_number) - print(f"[E2E] First contract: {contract.registry_number}") + self.assertGreater(len(response.data.contracts), 0) def test_get_contracts_fz223(self): - """Получение контрактов по 223-ФЗ.""" - from apps.parsers.clients.checko import ContractLaw, ContractsRequest + inn = _digits(10) - response = self.client.get_contracts( - ContractsRequest( - inn=TEST_INN, - law=ContractLaw.FZ223, - limit=10, + with TestHTTPServer() as server: + server.add_json( + "/v2/contracts", + { + "data": { + "contracts": [ + {"registry_number": _digits(12), "law": "223-FZ"}, + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_contracts( + ContractsRequest(inn=inn, law=ContractLaw.FZ223, limit=10) ) - ) self.assertEqual(response.meta.status, "ok") - print(f"\n[E2E] Found {len(response.data.contracts)} contracts (223-ФЗ)") + self.assertIsNotNone(response.data) def test_get_inspections(self): - """Получение проверок.""" - from apps.parsers.clients.checko import InspectionsRequest + inn = _digits(10) - response = self.client.get_inspections( - InspectionsRequest(inn=TEST_INN, limit=10) - ) + with TestHTTPServer() as server: + server.add_json( + "/v2/inspections", + { + "data": { + "inspections": [ + { + "id": fake.uuid4(), + "status": fake.word(), + "authority_name": fake.company(), + "subject": fake.word(), + } + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_inspections(InspectionsRequest(inn=inn, limit=10)) self.assertEqual(response.meta.status, "ok") self.assertIsNotNone(response.data) - - print(f"\n[E2E] Found {len(response.data.inspections)} inspections") - if response.data.pagination: - print(f"[E2E] Total inspections: {response.data.pagination.total_records}") + self.assertGreater(len(response.data.inspections), 0) def test_get_enforcements(self): - """Получение исполнительных производств.""" - from apps.parsers.clients.checko import EnforcementsRequest + inn = _digits(10) - response = self.client.get_enforcements( - EnforcementsRequest(inn=TEST_INN, limit=10) - ) + with TestHTTPServer() as server: + server.add_json( + "/v2/enforcements", + { + "data": { + "enforcements": [ + { + "number": _digits(12), + "status": fake.word(), + "debt_amount": fake.random_int(1_000, 10_000), + } + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + "total_debt": fake.random_int(1_000, 100_000), + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_enforcements(EnforcementsRequest(inn=inn, limit=10)) self.assertEqual(response.meta.status, "ok") self.assertIsNotNone(response.data) - - print(f"\n[E2E] Found {len(response.data.enforcements)} enforcements") - if response.data.total_debt: - print(f"[E2E] Total debt: {response.data.total_debt:,} руб.") + self.assertGreater(len(response.data.enforcements), 0) def test_get_legal_cases(self): - """Получение арбитражных дел.""" - from apps.parsers.clients.checko import LegalCasesRequest + inn = _digits(10) - response = self.client.get_legal_cases( - LegalCasesRequest(inn=TEST_INN, limit=10) - ) + with TestHTTPServer() as server: + server.add_json( + "/v2/legal-cases", + { + "data": { + "cases": [ + { + "case_number": fake.bothify(text="A-####/##"), + "status": fake.word(), + }, + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + "total_claim_amount": fake.random_int(1000, 10000), + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_legal_cases(LegalCasesRequest(inn=inn, limit=10)) + + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.data) + self.assertGreater(len(response.data.cases), 0) + + def test_get_legal_cases_filtered(self): + inn = _digits(10) + + with TestHTTPServer() as server: + server.add_json( + "/v2/legal-cases", + { + "data": { + "cases": [ + { + "case_number": fake.bothify(text="A-####/##"), + "status": fake.word(), + }, + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_legal_cases( + LegalCasesRequest(inn=inn, actual=True, active=True, limit=5) + ) self.assertEqual(response.meta.status, "ok") self.assertIsNotNone(response.data) - print(f"\n[E2E] Found {len(response.data.cases)} legal cases") - if response.data.pagination: - print(f"[E2E] Total cases: {response.data.pagination.total_records}") - if response.data.total_claim_amount: - print(f"[E2E] Total claims: {response.data.total_claim_amount:,} руб.") - # Проверка первого дела - if response.data.cases: - case = response.data.cases[0] - self.assertIsNotNone(case.case_number) - print(f"[E2E] First case: {case.case_number}") - - def test_get_legal_cases_filtered(self): - """Получение активных арбитражных дел.""" - from apps.parsers.clients.checko import LegalCasesRequest - - response = self.client.get_legal_cases( - LegalCasesRequest( - inn=TEST_INN, - actual=True, - active=True, - limit=5, - ) - ) - - self.assertEqual(response.meta.status, "ok") - print(f"\n[E2E] Found {len(response.data.cases)} active/actual cases") - - -@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") class CheckoClientIteratorsE2ETestCase(TestCase): - """ - E2E тесты итераторов с автопагинацией. - """ - - @classmethod - def setUpClass(cls): - """Подготовка класса.""" - super().setUpClass() - from apps.parsers.clients.checko import CheckoClient - - cls.client = CheckoClient(api_key=CHECKO_API_KEY, timeout=60) - - @classmethod - def tearDownClass(cls): - """Очистка класса.""" - cls.client.close() - super().tearDownClass() + """E2E тесты итераторов с автопагинацией.""" def test_iter_contracts_pagination(self): - """Итератор контрактов с пагинацией.""" - from apps.parsers.clients.checko import ContractLaw, ContractsRequest + inn = _digits(10) - contracts = [] - for contract in self.client.iter_contracts( - ContractsRequest(inn=TEST_INN, law=ContractLaw.FZ44, limit=5) - ): - contracts.append(contract) - if len(contracts) >= 15: # Ограничиваем для теста - break + def contracts_handler(request, _body): + page = int(parse_qs(request.query).get("page", ["1"])[0]) + total_pages = 3 + contracts = [ + {"registry_number": f"{page}-{_digits(10)}", "law": "44-FZ"}, + ] + return _json_response( + { + "data": { + "contracts": contracts, + "pagination": { + "total_records": total_pages, + "total_pages": total_pages, + "current_page": page, + }, + }, + "meta": _meta_ok(), + } + ) - print(f"\n[E2E] Iterated over {len(contracts)} contracts") - self.assertGreater(len(contracts), 0) + with TestHTTPServer() as server: + server.add_route("GET", "/v2/contracts", contracts_handler) + client = _client_for(server) + contracts = list( + client.iter_contracts( + ContractsRequest(inn=inn, law=ContractLaw.FZ44, limit=1) + ) + ) + + self.assertGreater(len(contracts), 1) def test_iter_legal_cases_pagination(self): - """Итератор арбитражных дел с пагинацией.""" - from apps.parsers.clients.checko import LegalCasesRequest + inn = _digits(10) - cases = [] - for case in self.client.iter_legal_cases( - LegalCasesRequest(inn=TEST_INN, limit=5) - ): - cases.append(case) - if len(cases) >= 15: # Ограничиваем для теста - break + def cases_handler(request, _body): + page = int(parse_qs(request.query).get("page", ["1"])[0]) + total_pages = 2 + cases = [ + {"case_number": f"A-{page}-{_digits(5)}", "status": fake.word()}, + ] + return _json_response( + { + "data": { + "cases": cases, + "pagination": { + "total_records": total_pages, + "total_pages": total_pages, + "current_page": page, + }, + }, + "meta": _meta_ok(), + } + ) - print(f"\n[E2E] Iterated over {len(cases)} legal cases") + with TestHTTPServer() as server: + server.add_route("GET", "/v2/legal-cases", cases_handler) + client = _client_for(server) + cases = list(client.iter_legal_cases(LegalCasesRequest(inn=inn, limit=1))) + + self.assertGreater(len(cases), 1) -@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") class CheckoClientErrorE2ETestCase(TestCase): - """ - E2E тесты обработки ошибок. - """ - - @classmethod - def setUpClass(cls): - """Подготовка класса.""" - super().setUpClass() - from apps.parsers.clients.checko import CheckoClient - - cls.client = CheckoClient(api_key=CHECKO_API_KEY, timeout=60) - - @classmethod - def tearDownClass(cls): - """Очистка класса.""" - cls.client.close() - super().tearDownClass() + """E2E тесты обработки ошибок.""" def test_company_not_found(self): - """Компания не найдена.""" - from apps.parsers.clients.checko import CheckoNotFoundError, CompanyRequest - - # Несуществующий ИНН - try: - self.client.get_company(CompanyRequest(inn="0000000000")) - self.fail("Expected CheckoNotFoundError") - except CheckoNotFoundError as e: - print(f"\n[E2E] Expected error: {e}") - self.assertIn("не найден", str(e).lower()) + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + { + "data": None, + "meta": {"status": "error", "message": "не найден"}, + }, + ) + client = _client_for(server) + with self.assertRaises(CheckoNotFoundError): + client.get_company(CompanyRequest(inn=_digits(10))) def test_invalid_api_key(self): - """Некорректный API ключ.""" - from apps.parsers.clients.checko import ( - CheckoAPIError, - CheckoClient, - CompanyRequest, - ) - - bad_client = CheckoClient(api_key="invalid_key_12345") - - try: - bad_client.get_company(CompanyRequest(inn=TEST_INN)) - self.fail("Expected CheckoAPIError") - except CheckoAPIError as e: - print(f"\n[E2E] Expected error: {e}") - finally: - bad_client.close() + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + { + "data": None, + "meta": {"status": "error", "message": "Invalid API key"}, + }, + ) + client = _client_for(server, api_key="invalid_key") + with self.assertRaises(CheckoAPIError): + client.get_company(CompanyRequest(inn=_digits(10))) -@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") class CheckoDatasetsE2ETestCase(TestCase): - """ - E2E тесты справочников. - - Проверяют загрузку и работу справочников с реальными данными. - """ + """E2E тесты справочников (локальные JSON данные).""" def test_okved2_load_and_search(self): - """Загрузка и поиск в ОКВЭД2.""" - from apps.parsers.clients.checko.datasets import OKVED2 + items = OKVED2.all() + self.assertGreater(len(items), 0) + sample = items[0] + self.assertIsNotNone(OKVED2.get(sample.code)) - # Получение по коду - item = OKVED2.get("62.01") - self.assertIsNotNone(item) - self.assertEqual(item.code, "62.01") - print(f"\n[E2E] OKVED2 62.01: {item.name}") - - # Поиск - results = OKVED2.search("программ") + search_term = sample.name.split(" ")[0] + results = OKVED2.search(search_term) self.assertGreater(len(results), 0) - print(f"[E2E] Search 'программ': {len(results)} results") - - # Иерархия - children = OKVED2.get_children("62") - print(f"[E2E] Children of 62: {len(children)} items") def test_okfs_load(self): - """Загрузка ОКФС.""" - from apps.parsers.clients.checko.datasets import OKFS - items = OKFS.all() self.assertGreater(len(items), 0) - print(f"\n[E2E] OKFS: {len(items)} items") def test_okopf_load(self): - """Загрузка ОКОПФ.""" - from apps.parsers.clients.checko.datasets import OKOPF - items = OKOPF.all() self.assertGreater(len(items), 0) - print(f"\n[E2E] OKOPF: {len(items)} items") def test_account_codes_load(self): - """Загрузка кодов строк отчётности.""" - from apps.parsers.clients.checko.datasets import AccountCodes - - item = AccountCodes.get("1100") - self.assertIsNotNone(item) - print(f"\n[E2E] Account code 1100: {item.name}") + items = AccountCodes.all() + self.assertGreater(len(items), 0) + sample = items[0] + self.assertIsNotNone(AccountCodes.get(sample.code)) diff --git a/src/apps/parsers/tests/test_e2e.py b/src/apps/parsers/tests/test_e2e.py index 67d348d..5b3557a 100644 --- a/src/apps/parsers/tests/test_e2e.py +++ b/src/apps/parsers/tests/test_e2e.py @@ -1,94 +1,137 @@ """ E2E тесты для парсера zakupki.gov.ru. -Тесты с реальной загрузкой данных. - -Для запуска E2E тестов с реальными данными: - RUN_E2E_TESTS=1 uv run python manage.py test apps.parsers.tests.test_e2e - -Тесты пропускаются по умолчанию, чтобы не нагружать внешние API -в обычных тестовых прогонах. +Тесты выполняются через локальный HTTP сервер (без внешних API). """ -import os -import unittest +from __future__ import annotations + +from urllib.parse import urlparse from apps.parsers.clients.zakupki import ZakupkiClient from apps.parsers.models import ParserLoadLog, ProcurementRecord from apps.parsers.services import ParserLoadLogService, ProcurementService -from django.conf import settings from django.test import TestCase, override_settings -# Флаг для запуска E2E тестов -RUN_E2E_TESTS = os.environ.get("RUN_E2E_TESTS", "").lower() in ("1", "true", "yes") - -# Токен из settings (или переменной окружения) -ZAKUPKI_TOKEN = getattr(settings, "ZAKUPKI_TOKEN", "") or os.environ.get( - "ZAKUPKI_TOKEN", "" -) +from tests.utils import TestHTTPServer +from tests.utils.fixtures import build_zakupki_xml, build_zip, fake + + +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +def _region_code() -> str: + return str(fake.random_int(min=1, max=99)).zfill(2) + + +def _host(server: TestHTTPServer) -> str: + parsed = urlparse(server.base_url) + if parsed.port: + return f"{parsed.hostname}:{parsed.port}" + return parsed.hostname or "" + + +def _add_zakupki_zip( + server: TestHTTPServer, + *, + region_code: str, + year: int, + month: int, + law_type: str = "44", + count: int = 3, +) -> int: + xml_bytes, rows = build_zakupki_xml(count=count) + zip_bytes = build_zip([(f"data_{region_code}_{year}_{month}.xml", xml_bytes)]) + fz_suffix = f"fz{law_type}" + path = ( + "/opendata/download/notifications/" + f"{region_code}/{year}/{month:02d}/{fz_suffix}.zip" + ) + server.add_bytes(path, zip_bytes, content_type="application/zip") + return len(rows) + + +def _proxy_address() -> str: + return f"http://{fake.ipv4()}:{fake.port_number()}" -@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") class ZakupkiClientE2ETestCase(TestCase): """ - E2E тесты клиента ZakupkiClient. - - Выполняют реальные HTTP запросы к zakupki.gov.ru. + E2E тесты клиента ZakupkiClient через локальный HTTP сервер. """ - def setUp(self): - """Подготовка.""" - self.client = ZakupkiClient(token=ZAKUPKI_TOKEN, timeout=60) - - def tearDown(self): - """Очистка.""" - self.client.close() - def test_fetch_procurement_plans(self): """Получение списка файлов для загрузки.""" - plans = self.client.fetch_procurement_plans(region_code="77", year=2025) + region_code = _region_code() + year = fake.random_int(min=2020, max=2026) + client = ZakupkiClient() + + plans = client.fetch_procurement_plans(region_code=region_code, year=year) self.assertIsInstance(plans, list) - # Планы должны быть сгенерированы даже без реальных данных self.assertGreater(len(plans), 0) for plan in plans: - self.assertEqual(plan.region_code, "77") - self.assertEqual(plan.year, 2025) + self.assertEqual(plan.region_code, region_code) + self.assertEqual(plan.year, year) - def test_fetch_procurements_with_invalid_region(self): - """Загрузка с несуществующим регионом возвращает пустой список.""" - # Используем несуществующий код региона - procurements = self.client.fetch_procurements( - region_code="99", - year=2025, - month=1, - ) + def test_fetch_procurements_with_missing_file(self): + """Загрузка с отсутствующим файлом возвращает пустой список.""" + region_code = _region_code() + year = fake.random_int(min=2020, max=2026) + month = fake.random_int(min=1, max=12) + + with TestHTTPServer() as server: + client = ZakupkiClient( + host=_host(server), + scheme="http", + timeout=5, + http_adapter=server.adapter, + ) + procurements = client.fetch_procurements( + region_code=region_code, + year=year, + month=month, + ) - # Должен вернуть пустой список, а не упасть self.assertIsInstance(procurements, list) + self.assertEqual(procurements, []) def test_fetch_with_progress_callback(self): """Тест callback для отслеживания прогресса.""" - progress_updates = [] + progress_updates: list[dict[str, object]] = [] + region_code = _region_code() + year = fake.random_int(min=2020, max=2026) + month = fake.random_int(min=1, max=12) def progress_callback(percent: int, message: str) -> None: progress_updates.append({"percent": percent, "message": message}) - self.client.fetch_procurements( - region_code="77", - year=2025, - month=1, - progress_callback=progress_callback, - ) + with TestHTTPServer() as server: + _add_zakupki_zip( + server, + region_code=region_code, + year=year, + month=month, + ) + client = ZakupkiClient( + host=_host(server), + scheme="http", + timeout=5, + http_adapter=server.adapter, + ) + client.fetch_procurements( + region_code=region_code, + year=year, + month=month, + progress_callback=progress_callback, + ) - # Должен быть хотя бы один вызов callback self.assertGreater(len(progress_updates), 0) - # Первый вызов должен быть с 0% self.assertEqual(progress_updates[0]["percent"], 0) -@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") class ProcurementServiceE2ETestCase(TestCase): """ E2E тесты сервиса ProcurementService. @@ -98,9 +141,11 @@ class ProcurementServiceE2ETestCase(TestCase): def test_full_load_cycle(self): """Полный цикл загрузки данных.""" - # Подготовка source = ParserLoadLog.Source.PROCUREMENTS batch_id = ParserLoadLogService.get_next_batch_id(source) + region_code = _region_code() + year = fake.random_int(min=2020, max=2026) + month = fake.random_int(min=1, max=12) load_log = ParserLoadLogService.create_load_log( source=source, @@ -108,22 +153,32 @@ class ProcurementServiceE2ETestCase(TestCase): status="in_progress", ) - # Загрузка данных - with ZakupkiClient(token=ZAKUPKI_TOKEN, timeout=60) as client: - procurements = client.fetch_procurements( - region_code="77", - year=2025, - month=1, + with TestHTTPServer() as server: + expected_count = _add_zakupki_zip( + server, + region_code=region_code, + year=year, + month=month, ) + with ZakupkiClient( + host=_host(server), + scheme="http", + timeout=5, + http_adapter=server.adapter, + ) as client: + procurements = client.fetch_procurements( + region_code=region_code, + year=year, + month=month, + ) - # Сохранение в БД if procurements: saved_count = ProcurementService.save_procurements( procurements, batch_id=batch_id, - region_code="77", - data_year=2025, - data_month=1, + region_code=region_code, + data_year=year, + data_month=month, ) ParserLoadLogService.update( @@ -132,24 +187,21 @@ class ProcurementServiceE2ETestCase(TestCase): records_count=saved_count, ) - # Проверки self.assertGreater(saved_count, 0) + self.assertEqual(saved_count, expected_count) self.assertEqual(ProcurementRecord.objects.count(), saved_count) - # Проверяем что данные корректны record = ProcurementRecord.objects.first() self.assertIsNotNone(record.purchase_number) - self.assertEqual(record.region_code, "77") - self.assertEqual(record.data_year, 2025) - self.assertEqual(record.data_month, 1) + self.assertEqual(record.region_code, region_code) + self.assertEqual(record.data_year, year) + self.assertEqual(record.data_month, month) self.assertEqual(record.load_batch, batch_id) - # Проверяем лог load_log.refresh_from_db() self.assertEqual(load_log.status, "success") self.assertEqual(load_log.records_count, saved_count) else: - # Если данных нет - это тоже валидный результат ParserLoadLogService.update( load_log, status="success", @@ -157,7 +209,6 @@ class ProcurementServiceE2ETestCase(TestCase): ) -@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") @override_settings( CELERY_TASK_ALWAYS_EAGER=True, CELERY_TASK_EAGER_PROPAGATES=True, @@ -166,7 +217,7 @@ class ProcurementTasksE2ETestCase(TestCase): """ E2E тесты Celery задач. - Запускает реальные задачи в синхронном режиме. + Запускает задачи в синхронном режиме с локальным HTTP сервером. """ def test_parse_procurements_task(self): @@ -175,30 +226,38 @@ class ProcurementTasksE2ETestCase(TestCase): from apps.parsers.tasks import parse_procurements - # Запуск задачи синхронно через .apply() с явным task_id - # (CELERY_TASK_ALWAYS_EAGER=True, но self.request.id = None без apply) - task_id = str(uuid.uuid4()) - async_result = parse_procurements.apply( - kwargs={ - "region_code": "77", - "year": 2025, - "month": 1, - "law_type": "44", - }, - task_id=task_id, - ) - result = async_result.result + region_code = _region_code() + year = fake.random_int(min=2020, max=2026) + month = fake.random_int(min=1, max=12) + + with TestHTTPServer() as server: + _add_zakupki_zip( + server, + region_code=region_code, + year=year, + month=month, + ) + task_id = str(uuid.uuid4()) + async_result = parse_procurements.apply( + kwargs={ + "region_code": region_code, + "year": year, + "month": month, + "law_type": "44", + "client_host": _host(server), + "client_scheme": "http", + "client_adapter": server.adapter, + }, + task_id=task_id, + ) + result = async_result.result - # Проверки self.assertIn("batch_id", result) self.assertIn("status", result) self.assertIn("saved", result) - - # Статус должен быть success или failed (не упасть с исключением) self.assertIn(result["status"], ["success", "failed"]) if result["status"] == "success": - # Если успех - проверяем что batch_id корректен self.assertGreater(result["batch_id"], 0) @@ -206,20 +265,17 @@ class ZakupkiClientOfflineTestCase(TestCase): """ Тесты клиента без реальных HTTP запросов. - Эти тесты выполняются всегда и проверяют - обработку ошибок и edge cases. + Проверяет обработку ошибок и edge cases. """ def test_client_handles_connection_error(self): """Клиент корректно обрабатывает ошибки соединения.""" - # Используем невалидный хост - client = ZakupkiClient(host="invalid.host.local", timeout=5) + client = ZakupkiClient(host="127.0.0.1:1", scheme="http", timeout=2) - # Должен вернуть пустой список, а не упасть procurements = client.fetch_procurements( - region_code="77", - year=2025, - file_url="http://invalid.host.local/data.xml", + region_code=_region_code(), + year=fake.random_int(min=2020, max=2026), + file_url="http://127.0.0.1:1/data.xml", ) self.assertEqual(procurements, []) @@ -229,7 +285,6 @@ class ZakupkiClientOfflineTestCase(TestCase): """Клиент обрабатывает пустой ответ.""" client = ZakupkiClient() - # Пустой XML procurements = client._parse_xml_content( b'', None, @@ -248,7 +303,7 @@ class ProxyIntegrationTestCase(TestCase): def test_client_accepts_proxy_list(self): """Клиент принимает список прокси.""" - proxies = ["http://proxy1:8080", "http://proxy2:8080"] + proxies = [_proxy_address(), _proxy_address()] client = ZakupkiClient(proxies=proxies) self.assertEqual(client.proxies, proxies) @@ -266,17 +321,14 @@ class ProxyIntegrationTestCase(TestCase): from apps.parsers.services import ProxyService from apps.parsers.tests.factories import ProxyFactory - # Создаём активные прокси ProxyFactory.create_batch(3, is_active=True) ProxyFactory.create_batch(2, is_active=False) - # Получаем активные прокси proxies = ProxyService.get_active_proxies_or_none() self.assertIsNotNone(proxies) self.assertEqual(len(proxies), 3) - # Создаём клиент с этими прокси client = ZakupkiClient(proxies=proxies) self.assertEqual(len(client.proxies), 3) client.close() diff --git a/src/apps/parsers/tests/test_fns_parser.py b/src/apps/parsers/tests/test_fns_parser.py index f3d739a..1dbe07c 100644 --- a/src/apps/parsers/tests/test_fns_parser.py +++ b/src/apps/parsers/tests/test_fns_parser.py @@ -8,48 +8,74 @@ from apps.parsers.models import FinancialReport from apps.parsers.services import FNSReportService from django.test import TestCase +from tests.utils.fixtures import fake + + +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +def _year() -> int: + return fake.random_int(min=2020, max=2026) + + +def _line_code() -> str: + return _digits(4) + + +def _form_code() -> str: + return str(fake.random_int(min=1, max=6)) + class TestFNSExcelParserFilename(TestCase): """Тесты парсинга имени файла.""" def test_parse_valid_filename(self): """Корректное имя файла.""" - external_id, ogrn = FNSExcelParser.parse_filename( - "fin_0000605_1027700169089.xlsx" + external_id = _digits(7) + ogrn = _digits(13) + external_id_parsed, ogrn_parsed = FNSExcelParser.parse_filename( + f"fin_{external_id}_{ogrn}.xlsx" ) - self.assertEqual(external_id, "0000605") - self.assertEqual(ogrn, "1027700169089") + self.assertEqual(external_id_parsed, external_id) + self.assertEqual(ogrn_parsed, ogrn) def test_parse_filename_with_long_id(self): """Имя файла с длинным ID.""" - external_id, ogrn = FNSExcelParser.parse_filename( - "fin_12345678_1027700169089.xlsx" + external_id = _digits(8) + ogrn = _digits(13) + external_id_parsed, ogrn_parsed = FNSExcelParser.parse_filename( + f"fin_{external_id}_{ogrn}.xlsx" ) - self.assertEqual(external_id, "12345678") - self.assertEqual(ogrn, "1027700169089") + self.assertEqual(external_id_parsed, external_id) + self.assertEqual(ogrn_parsed, ogrn) def test_parse_filename_with_15_digit_ogrn(self): """Имя файла с 15-значным ОГРН (ИП).""" - external_id, ogrn = FNSExcelParser.parse_filename( - "fin_123_123456789012345.xlsx" + external_id = _digits(3) + ogrn = _digits(15) + external_id_parsed, ogrn_parsed = FNSExcelParser.parse_filename( + f"fin_{external_id}_{ogrn}.xlsx" ) - self.assertEqual(external_id, "123") - self.assertEqual(ogrn, "123456789012345") + self.assertEqual(external_id_parsed, external_id) + self.assertEqual(ogrn_parsed, ogrn) def test_parse_invalid_filename_no_fin_prefix(self): """Неверный формат - без префикса fin.""" with self.assertRaises(FNSParserError): - FNSExcelParser.parse_filename("report_123_1234567890123.xlsx") + FNSExcelParser.parse_filename( + f"{fake.word()}_{_digits(3)}_{_digits(13)}.xlsx" + ) def test_parse_invalid_filename_wrong_extension(self): """Неверный формат - неправильное расширение.""" with self.assertRaises(FNSParserError): - FNSExcelParser.parse_filename("fin_123_1234567890123.xls") + FNSExcelParser.parse_filename(f"fin_{_digits(3)}_{_digits(13)}.xls") def test_parse_invalid_filename_short_ogrn(self): """Неверный формат - короткий ОГРН.""" with self.assertRaises(FNSParserError): - FNSExcelParser.parse_filename("fin_123_123456789.xlsx") + FNSExcelParser.parse_filename(f"fin_{_digits(3)}_{_digits(12)}.xlsx") class TestReportLineSchema(TestCase): @@ -57,27 +83,33 @@ class TestReportLineSchema(TestCase): def test_create_report_line(self): """Создание строки отчета.""" + form_code = _form_code() + line_code = _line_code() + line_name = fake.word() + year = _year() + period_start = fake.random_int(min=10, max=1000) + period_end = fake.random_int(min=10, max=1000) line = ReportLine( - form_code="1", - line_code="1100", - line_name="Баланс", - year=2023, - period_start=1000, - period_end=2000, + form_code=form_code, + line_code=line_code, + line_name=line_name, + year=year, + period_start=period_start, + period_end=period_end, ) - self.assertEqual(line.form_code, "1") - self.assertEqual(line.line_code, "1100") - self.assertEqual(line.year, 2023) - self.assertEqual(line.period_start, 1000) - self.assertEqual(line.period_end, 2000) + self.assertEqual(line.form_code, form_code) + self.assertEqual(line.line_code, line_code) + self.assertEqual(line.year, year) + self.assertEqual(line.period_start, period_start) + self.assertEqual(line.period_end, period_end) def test_report_line_with_none_values(self): """Строка отчета с пустыми значениями.""" line = ReportLine( - form_code="2", - line_code="2110", - line_name="Выручка", - year=2023, + form_code=_form_code(), + line_code=_line_code(), + line_name=fake.word(), + year=_year(), ) self.assertIsNone(line.period_start) self.assertIsNone(line.period_end) @@ -88,74 +120,97 @@ class TestParsedReportSchema(TestCase): def test_create_parsed_report(self): """Создание отчета.""" + external_id = _digits(3) + ogrn = _digits(13) + year_a = _year() + year_b = year_a - 1 if year_a > 2020 else year_a + 1 + year_c = year_a + 1 if year_a < 2026 else year_a - 1 report = ParsedReport( - external_id="123", - ogrn="1234567890123", + external_id=external_id, + ogrn=ogrn, lines=[ - ReportLine("1", "1100", "Баланс", 2023, 100, 200), - ReportLine("1", "1100", "Баланс", 2022, 50, 100), - ReportLine("2", "2110", "Выручка", 2023, 1000, 1500), + ReportLine(_form_code(), _line_code(), fake.word(), year_a, 100, 200), + ReportLine(_form_code(), _line_code(), fake.word(), year_b, 50, 100), + ReportLine(_form_code(), _line_code(), fake.word(), year_c, 1000, 1500), ], ) - self.assertEqual(report.external_id, "123") - self.assertEqual(report.ogrn, "1234567890123") + self.assertEqual(report.external_id, external_id) + self.assertEqual(report.ogrn, ogrn) self.assertEqual(len(report.lines), 3) def test_report_years(self): """Получение годов из отчета.""" + year_a = _year() + year_b = year_a - 1 if year_a > 2020 else year_a + 1 + year_c = year_a + 1 if year_a < 2026 else year_a - 1 report = ParsedReport( - external_id="123", - ogrn="1234567890123", + external_id=_digits(3), + ogrn=_digits(13), lines=[ - ReportLine("1", "1100", "Баланс", 2023, 100, 200), - ReportLine("1", "1100", "Баланс", 2022, 50, 100), - ReportLine("1", "1100", "Баланс", 2021, 30, 50), + ReportLine(_form_code(), _line_code(), fake.word(), year_a, 100, 200), + ReportLine(_form_code(), _line_code(), fake.word(), year_b, 50, 100), + ReportLine(_form_code(), _line_code(), fake.word(), year_c, 30, 50), ], ) - self.assertEqual(report.years, {2021, 2022, 2023}) + self.assertEqual(report.years, {year_a, year_b, year_c}) def test_report_forms(self): """Получение форм из отчета.""" + form_a = _form_code() + form_b = _form_code() + form_c = _form_code() report = ParsedReport( - external_id="123", - ogrn="1234567890123", + external_id=_digits(3), + ogrn=_digits(13), lines=[ - ReportLine("1", "1100", "Баланс", 2023, 100, 200), - ReportLine("2", "2110", "Выручка", 2023, 1000, 1500), - ReportLine("4", "4100", "Сальдо", 2023, 500, 600), + ReportLine(form_a, _line_code(), fake.word(), _year(), 100, 200), + ReportLine(form_b, _line_code(), fake.word(), _year(), 1000, 1500), + ReportLine(form_c, _line_code(), fake.word(), _year(), 500, 600), ], ) - self.assertEqual(report.forms, {"1", "2", "4"}) + self.assertEqual(report.forms, {form_a, form_b, form_c}) def test_get_lines_by_form(self): """Фильтрация строк по форме.""" + form_target = _form_code() + form_other = _form_code() + while form_other == form_target: + form_other = _form_code() report = ParsedReport( - external_id="123", - ogrn="1234567890123", + external_id=_digits(3), + ogrn=_digits(13), lines=[ - ReportLine("1", "1100", "Баланс", 2023, 100, 200), - ReportLine("2", "2110", "Выручка", 2023, 1000, 1500), - ReportLine("1", "1200", "Активы", 2023, 300, 400), + ReportLine(form_target, _line_code(), fake.word(), _year(), 100, 200), + ReportLine(form_other, _line_code(), fake.word(), _year(), 1000, 1500), + ReportLine(form_target, _line_code(), fake.word(), _year(), 300, 400), ], ) - form1_lines = report.get_lines_by_form("1") + form1_lines = report.get_lines_by_form(form_target) self.assertEqual(len(form1_lines), 2) - self.assertTrue(all(line.form_code == "1" for line in form1_lines)) + self.assertTrue(all(line.form_code == form_target for line in form1_lines)) def test_get_lines_by_year(self): """Фильтрация строк по году.""" + year_target = _year() + year_other = year_target - 1 if year_target > 2020 else year_target + 1 report = ParsedReport( - external_id="123", - ogrn="1234567890123", + external_id=_digits(3), + ogrn=_digits(13), lines=[ - ReportLine("1", "1100", "Баланс", 2023, 100, 200), - ReportLine("1", "1100", "Баланс", 2022, 50, 100), - ReportLine("1", "1100", "Баланс", 2023, 150, 250), + ReportLine( + _form_code(), _line_code(), fake.word(), year_target, 100, 200 + ), + ReportLine( + _form_code(), _line_code(), fake.word(), year_other, 50, 100 + ), + ReportLine( + _form_code(), _line_code(), fake.word(), year_target, 150, 250 + ), ], ) - year_2023_lines = report.get_lines_by_year(2023) - self.assertEqual(len(year_2023_lines), 2) - self.assertTrue(all(line.year == 2023 for line in year_2023_lines)) + year_lines = report.get_lines_by_year(year_target) + self.assertEqual(len(year_lines), 2) + self.assertTrue(all(line.year == year_target for line in year_lines)) class TestFNSParserParseValue(TestCase): @@ -163,23 +218,29 @@ class TestFNSParserParseValue(TestCase): def test_parse_integer(self): """Парсинг целого числа.""" - self.assertEqual(FNSExcelParser._parse_value(100), 100) + value = fake.random_int(min=1, max=1000) + self.assertEqual(FNSExcelParser._parse_value(value), value) def test_parse_float(self): """Парсинг числа с плавающей точкой.""" - self.assertEqual(FNSExcelParser._parse_value(100.5), 100) + value = fake.random_int(min=1, max=1000) + self.assertEqual(FNSExcelParser._parse_value(value + 0.5), value) def test_parse_string_integer(self): """Парсинг строкового числа.""" - self.assertEqual(FNSExcelParser._parse_value("100"), 100) + value = fake.random_int(min=1, max=1000) + self.assertEqual(FNSExcelParser._parse_value(str(value)), value) def test_parse_string_with_comma(self): """Парсинг числа с запятой.""" - self.assertEqual(FNSExcelParser._parse_value("100,5"), 100) + value = fake.random_int(min=1, max=1000) + self.assertEqual(FNSExcelParser._parse_value(f"{value},5"), value) def test_parse_string_with_spaces(self): """Парсинг числа с пробелами.""" - self.assertEqual(FNSExcelParser._parse_value("1 000"), 1000) + value = fake.random_int(min=1000, max=999999) + formatted = f"{value:,}".replace(",", " ") + self.assertEqual(FNSExcelParser._parse_value(formatted), value) def test_parse_none(self): """Парсинг None.""" @@ -195,7 +256,7 @@ class TestFNSParserParseValue(TestCase): def test_parse_invalid_string(self): """Парсинг некорректной строки.""" - self.assertIsNone(FNSExcelParser._parse_value("abc")) + self.assertIsNone(FNSExcelParser._parse_value(fake.word())) class TestFNSReportServiceIntegration(TestCase): @@ -203,76 +264,81 @@ class TestFNSReportServiceIntegration(TestCase): def test_save_report(self): """Сохранение отчета.""" + external_id = _digits(6) + ogrn = _digits(13) + file_hash = fake.sha1(raw_output=False) lines_data = [ { - "form_code": "1", - "line_code": "1100", - "line_name": "Баланс", - "year": 2023, - "period_start": 100, - "period_end": 200, + "form_code": _form_code(), + "line_code": _line_code(), + "line_name": fake.word(), + "year": _year(), + "period_start": fake.random_int(min=10, max=1000), + "period_end": fake.random_int(min=10, max=1000), }, { - "form_code": "2", - "line_code": "2110", - "line_name": "Выручка", - "year": 2023, - "period_start": 1000, - "period_end": 1500, + "form_code": _form_code(), + "line_code": _line_code(), + "line_name": fake.word(), + "year": _year(), + "period_start": fake.random_int(min=10, max=1000), + "period_end": fake.random_int(min=10, max=1000), }, ] report = FNSReportService.save_report( - external_id="test_001", - ogrn="1234567890123", - file_name="fin_test_001_1234567890123.xlsx", - file_hash="abc123def456", + external_id=external_id, + ogrn=ogrn, + file_name=f"fin_{external_id}_{ogrn}.xlsx", + file_hash=file_hash, source=FinancialReport.SourceType.API, batch_id=1, lines_data=lines_data, ) self.assertIsNotNone(report.id) - self.assertEqual(report.external_id, "test_001") - self.assertEqual(report.ogrn, "1234567890123") + self.assertEqual(report.external_id, external_id) + self.assertEqual(report.ogrn, ogrn) self.assertEqual(report.status, FinancialReport.Status.SUCCESS) self.assertEqual(report.lines.count(), 2) def test_exists_by_hash(self): """Проверка существования по хешу.""" + unique_hash = fake.sha1(raw_output=False) FNSReportService.save_report( - external_id="test_hash_001", - ogrn="1234567890123", - file_name="test.xlsx", - file_hash="unique_hash_123", + external_id=_digits(6), + ogrn=_digits(13), + file_name=fake.file_name(extension="xlsx"), + file_hash=unique_hash, source=FinancialReport.SourceType.API, batch_id=1, lines_data=[], ) - self.assertTrue(FNSReportService.exists_by_hash("unique_hash_123")) - self.assertFalse(FNSReportService.exists_by_hash("nonexistent_hash")) + self.assertTrue(FNSReportService.exists_by_hash(unique_hash)) + self.assertFalse(FNSReportService.exists_by_hash(fake.sha1(raw_output=False))) def test_find_by_ogrn(self): """Поиск по ОГРН.""" + ogrn = _digits(13) FNSReportService.save_report( - external_id="test_ogrn_001", - ogrn="9876543210123", - file_name="test1.xlsx", - file_hash="hash1", + external_id=_digits(6), + ogrn=ogrn, + file_name=fake.file_name(extension="xlsx"), + file_hash=fake.sha1(raw_output=False), source=FinancialReport.SourceType.FILE_WATCH, batch_id=1, lines_data=[], ) FNSReportService.save_report( - external_id="test_ogrn_002", - ogrn="9876543210123", - file_name="test2.xlsx", - file_hash="hash2", + external_id=_digits(6), + ogrn=ogrn, + file_name=fake.file_name(extension="xlsx"), + file_hash=fake.sha1(raw_output=False), source=FinancialReport.SourceType.FILE_WATCH, batch_id=1, lines_data=[], ) - reports = FNSReportService.find_by_ogrn("9876543210123") + reports = FNSReportService.find_by_ogrn(ogrn) self.assertEqual(reports.count(), 2) diff --git a/src/apps/parsers/tests/test_procurement_service.py b/src/apps/parsers/tests/test_procurement_service.py index 6d41c28..1d18f30 100644 --- a/src/apps/parsers/tests/test_procurement_service.py +++ b/src/apps/parsers/tests/test_procurement_service.py @@ -7,10 +7,48 @@ Unit-тесты для ProcurementService. from apps.parsers.clients.zakupki.schemas import Procurement from apps.parsers.models import ProcurementRecord from apps.parsers.services import ProcurementService -from apps.parsers.tests.factories import ProcurementRecordFactory +from apps.parsers.tests.factories import ProcurementRecordFactory, fake from django.test import TestCase +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +def _region_code() -> str: + return str(fake.random_int(min=1, max=99)).zfill(2) + + +def _period() -> tuple[int, int]: + return fake.random_int(min=2020, max=2025), fake.random_int(min=1, max=12) + + +def _other_law(law_type: str) -> str: + return "223-FZ" if law_type == "44-FZ" else "44-FZ" + + +def _build_procurement(**overrides) -> Procurement: + data = { + "purchase_number": _digits(19), + "purchase_name": fake.sentence(nb_words=4), + "customer_inn": _digits(10), + "customer_kpp": _digits(9), + "customer_ogrn": _digits(13), + "customer_name": fake.company(), + "max_price": str(fake.random_int(min=10_000, max=10_000_000)), + "currency_code": fake.random_element(["RUB", "USD", "EUR"]), + "placement_method": fake.word(), + "publish_date": str(fake.date()), + "end_date": str(fake.date()), + "status": fake.word(), + "law_type": fake.random_element(["44-FZ", "223-FZ"]), + "purchase_object_info": fake.sentence(nb_words=6), + "href": fake.url(), + } + data.update(overrides) + return Procurement(**data) + + class ProcurementServiceSaveTestCase(TestCase): """Тесты метода save_procurements.""" @@ -21,62 +59,37 @@ class ProcurementServiceSaveTestCase(TestCase): def test_save_single_procurement(self): """Сохранение одной закупки.""" - procurement = Procurement( - purchase_number="1234567890123456789", - purchase_name="Test procurement", - customer_inn="1234567890", - customer_kpp="123456789", - customer_ogrn="1234567890123", - customer_name="Test Organization", - max_price="1000000", - currency_code="RUB", - placement_method="Electronic auction", - publish_date="2025-01-15", - end_date="2025-02-15", - status="Published", - law_type="44-FZ", - purchase_object_info="Test object", - href="https://zakupki.gov.ru/test", + purchase_number = _digits(19) + customer_inn = _digits(10) + region_code = _region_code() + year = fake.random_int(min=2020, max=2025) + month = fake.random_int(min=1, max=12) + procurement = _build_procurement( + purchase_number=purchase_number, + customer_inn=customer_inn, ) saved = ProcurementService.save_procurements( [procurement], batch_id=1, - region_code="77", - data_year=2025, - data_month=1, + region_code=region_code, + data_year=year, + data_month=month, ) self.assertEqual(saved, 1) self.assertEqual(ProcurementRecord.objects.count(), 1) record = ProcurementRecord.objects.first() - self.assertEqual(record.purchase_number, "1234567890123456789") - self.assertEqual(record.customer_inn, "1234567890") - self.assertEqual(record.region_code, "77") - self.assertEqual(record.data_year, 2025) - self.assertEqual(record.data_month, 1) + self.assertEqual(record.purchase_number, purchase_number) + self.assertEqual(record.customer_inn, customer_inn) + self.assertEqual(record.region_code, region_code) + self.assertEqual(record.data_year, year) + self.assertEqual(record.data_month, month) def test_save_multiple_procurements(self): """Сохранение нескольких закупок.""" - procurements = [ - Procurement( - purchase_number=f"{i:019d}", - purchase_name=f"Procurement {i}", - customer_inn=f"{i:010d}", - customer_kpp="", - customer_ogrn="", - customer_name=f"Organization {i}", - max_price=str(1000000 * i), - currency_code="RUB", - placement_method="", - publish_date="2025-01-01", - end_date="", - status="", - law_type="44-FZ", - ) - for i in range(1, 6) - ] + procurements = [_build_procurement() for _ in range(5)] saved = ProcurementService.save_procurements(procurements, batch_id=1) @@ -86,24 +99,14 @@ class ProcurementServiceSaveTestCase(TestCase): def test_save_ignores_duplicates(self): """Дубликаты по purchase_number пропускаются.""" # Создаём существующую запись - ProcurementRecordFactory(purchase_number="1234567890123456789") + purchase_number = _digits(19) + ProcurementRecordFactory(purchase_number=purchase_number) initial_count = ProcurementRecord.objects.count() # Пытаемся сохранить с тем же номером - procurement = Procurement( - purchase_number="1234567890123456789", - purchase_name="Duplicate", - customer_inn="9999999999", - customer_kpp="", - customer_ogrn="", - customer_name="Duplicate Org", - max_price="500000", - currency_code="RUB", - placement_method="", - publish_date="2025-01-01", - end_date="", - status="", - law_type="44-FZ", + procurement = _build_procurement( + purchase_number=purchase_number, + customer_inn=_digits(10), ) ProcurementService.save_procurements([procurement], batch_id=2) @@ -111,29 +114,12 @@ class ProcurementServiceSaveTestCase(TestCase): # Дубликат пропущен - количество записей не изменилось self.assertEqual(ProcurementRecord.objects.count(), initial_count) # Оригинальная запись не была перезаписана - original = ProcurementRecord.objects.get(purchase_number="1234567890123456789") - self.assertNotEqual(original.customer_inn, "9999999999") + original = ProcurementRecord.objects.get(purchase_number=purchase_number) + self.assertNotEqual(original.customer_inn, procurement.customer_inn) def test_save_with_chunking(self): """Сохранение большого количества записей чанками.""" - procurements = [ - Procurement( - purchase_number=f"{i:019d}", - purchase_name=f"Procurement {i}", - customer_inn=f"{i:010d}", - customer_kpp="", - customer_ogrn="", - customer_name=f"Organization {i}", - max_price=str(1000000), - currency_code="RUB", - placement_method="", - publish_date="2025-01-01", - end_date="", - status="", - law_type="44-FZ", - ) - for i in range(1, 101) # 100 записей - ] + procurements = [_build_procurement() for _ in range(100)] saved = ProcurementService.save_procurements( procurements, batch_id=1, chunk_size=25 @@ -148,61 +134,71 @@ class ProcurementServiceFindTestCase(TestCase): def setUp(self): """Подготовка тестовых данных.""" + self.inn_target = _digits(10) + self.inn_other = _digits(10) + self.region_a = _region_code() + self.region_b = _region_code() + while self.region_b == self.region_a: + self.region_b = _region_code() + self.name_key = fake.word() + self.unique_token = fake.word() self.record1 = ProcurementRecordFactory( - purchase_number="1111111111111111111", - customer_inn="1111111111", - customer_name="First Organization", - region_code="77", + purchase_number=_digits(19), + customer_inn=self.inn_target, + customer_name=f"{self.unique_token} {self.name_key} {fake.company()}", + region_code=self.region_a, load_batch=1, ) self.record2 = ProcurementRecordFactory( - purchase_number="2222222222222222222", - customer_inn="2222222222", - customer_name="Second Organization", - region_code="77", + purchase_number=_digits(19), + customer_inn=self.inn_other, + customer_name=f"{self.name_key} {fake.company()}", + region_code=self.region_a, load_batch=1, ) self.record3 = ProcurementRecordFactory( - purchase_number="3333333333333333333", - customer_inn="1111111111", # Тот же ИНН что и у первого - customer_name="Third Organization", - region_code="50", + purchase_number=_digits(19), + customer_inn=self.inn_target, # Тот же ИНН что и у первого + customer_name=f"{self.name_key} {fake.company()}", + region_code=self.region_b, load_batch=2, ) def test_find_by_inn(self): """Поиск по ИНН.""" - results = ProcurementService.find_by_inn("1111111111") + results = ProcurementService.find_by_inn(self.inn_target) self.assertEqual(results.count(), 2) def test_find_by_inn_with_batch(self): """Поиск по ИНН с фильтром по batch.""" - results = ProcurementService.find_by_inn("1111111111", batch_id=1) + results = ProcurementService.find_by_inn(self.inn_target, batch_id=1) self.assertEqual(results.count(), 1) - self.assertEqual(results.first().purchase_number, "1111111111111111111") + self.assertEqual(results.first().purchase_number, self.record1.purchase_number) def test_find_by_purchase_number(self): """Поиск по номеру закупки.""" - results = ProcurementService.find_by_purchase_number("2222222222222222222") + results = ProcurementService.find_by_purchase_number( + self.record2.purchase_number + ) self.assertEqual(results.count(), 1) - self.assertEqual(results.first().customer_inn, "2222222222") + self.assertEqual(results.first().customer_inn, self.record2.customer_inn) def test_find_by_region(self): """Поиск по региону.""" - results = ProcurementService.find_by_region("77") + results = ProcurementService.find_by_region(self.region_a) self.assertEqual(results.count(), 2) def test_find_by_region_with_batch(self): """Поиск по региону с фильтром по batch.""" - results = ProcurementService.find_by_region("77", batch_id=1) + results = ProcurementService.find_by_region(self.region_a, batch_id=1) self.assertEqual(results.count(), 2) def test_find_by_customer_name(self): """Поиск по названию заказчика (частичное совпадение).""" - results = ProcurementService.find_by_customer_name("Organization") + results = ProcurementService.find_by_customer_name(self.name_key) self.assertEqual(results.count(), 3) - results = ProcurementService.find_by_customer_name("First") + results = ProcurementService.find_by_customer_name(self.unique_token) self.assertEqual(results.count(), 1) @@ -217,67 +213,97 @@ class ProcurementServicePeriodTestCase(TestCase): def test_get_last_loaded_period(self): """Получение последнего загруженного периода.""" - ProcurementRecordFactory(data_year=2024, data_month=6) - ProcurementRecordFactory(data_year=2025, data_month=1) - ProcurementRecordFactory(data_year=2024, data_month=12) + periods = [_period() for _ in range(3)] + for year, month in periods: + ProcurementRecordFactory(data_year=year, data_month=month) + expected_year, expected_month = max(periods) year, month = ProcurementService.get_last_loaded_period() - self.assertEqual(year, 2025) - self.assertEqual(month, 1) + self.assertEqual(year, expected_year) + self.assertEqual(month, expected_month) def test_get_last_loaded_period_by_region(self): """Получение последнего периода с фильтром по региону.""" - ProcurementRecordFactory(data_year=2025, data_month=3, region_code="77") - ProcurementRecordFactory(data_year=2025, data_month=6, region_code="50") + region_a = _region_code() + region_b = _region_code() + while region_b == region_a: + region_b = _region_code() + period_a = _period() + period_b = _period() + ProcurementRecordFactory( + data_year=period_a[0], data_month=period_a[1], region_code=region_a + ) + ProcurementRecordFactory( + data_year=period_b[0], data_month=period_b[1], region_code=region_b + ) - year, month = ProcurementService.get_last_loaded_period(region_code="77") + year, month = ProcurementService.get_last_loaded_period(region_code=region_a) - self.assertEqual(year, 2025) - self.assertEqual(month, 3) + self.assertEqual(year, period_a[0]) + self.assertEqual(month, period_a[1]) def test_get_last_loaded_period_by_law_type(self): """Получение последнего периода с фильтром по типу закона.""" - ProcurementRecordFactory(data_year=2025, data_month=3, law_type="44-FZ") - ProcurementRecordFactory(data_year=2025, data_month=6, law_type="223-FZ") + law_type_a = fake.random_element(["44-FZ", "223-FZ"]) + law_type_b = _other_law(law_type_a) + period_a = _period() + period_b = _period() + ProcurementRecordFactory( + data_year=period_a[0], data_month=period_a[1], law_type=law_type_a + ) + ProcurementRecordFactory( + data_year=period_b[0], data_month=period_b[1], law_type=law_type_b + ) - year, month = ProcurementService.get_last_loaded_period(law_type="44-FZ") + year, month = ProcurementService.get_last_loaded_period(law_type=law_type_a) - self.assertEqual(year, 2025) - self.assertEqual(month, 3) + self.assertEqual(year, period_a[0]) + self.assertEqual(month, period_a[1]) def test_has_data_for_period_true(self): """Проверка наличия данных за период - есть данные.""" - ProcurementRecordFactory(data_year=2025, data_month=1) + year, month = _period() + ProcurementRecordFactory(data_year=year, data_month=month) - result = ProcurementService.has_data_for_period(2025, 1) + result = ProcurementService.has_data_for_period(year, month) self.assertTrue(result) def test_has_data_for_period_false(self): """Проверка наличия данных за период - нет данных.""" - ProcurementRecordFactory(data_year=2025, data_month=1) + year, month = _period() + ProcurementRecordFactory(data_year=year, data_month=month) + other_month = month % 12 + 1 - result = ProcurementService.has_data_for_period(2025, 2) + result = ProcurementService.has_data_for_period(year, other_month) self.assertFalse(result) def test_has_data_for_period_with_filters(self): """Проверка наличия данных с фильтрами.""" + year, month = _period() + region_code = _region_code() + law_type = fake.random_element(["44-FZ", "223-FZ"]) ProcurementRecordFactory( - data_year=2025, data_month=1, region_code="77", law_type="44-FZ" + data_year=year, + data_month=month, + region_code=region_code, + law_type=law_type, ) # С правильными фильтрами - есть self.assertTrue( ProcurementService.has_data_for_period( - 2025, 1, region_code="77", law_type="44-FZ" + year, month, region_code=region_code, law_type=law_type ) ) # С неправильным регионом - нет self.assertFalse( - ProcurementService.has_data_for_period(2025, 1, region_code="50") + ProcurementService.has_data_for_period( + year, month, region_code=_region_code() + ) ) @@ -295,7 +321,7 @@ class ProcurementServiceBaseMethodsTestCase(TestCase): self.assertTrue( ProcurementService.exists(purchase_number=record.purchase_number) ) - self.assertFalse(ProcurementService.exists(purchase_number="nonexistent")) + self.assertFalse(ProcurementService.exists(purchase_number=_digits(19))) def test_get_by_id(self): """Получение по ID.""" @@ -311,9 +337,11 @@ class ProcurementServiceBaseMethodsTestCase(TestCase): def test_filter(self): """Фильтрация записей.""" - ProcurementRecordFactory(law_type="44-FZ") - ProcurementRecordFactory(law_type="44-FZ") - ProcurementRecordFactory(law_type="223-FZ") + law_type = fake.random_element(["44-FZ", "223-FZ"]) + other_law = _other_law(law_type) + ProcurementRecordFactory(law_type=law_type) + ProcurementRecordFactory(law_type=law_type) + ProcurementRecordFactory(law_type=other_law) - filtered = ProcurementService.filter(law_type="44-FZ") + filtered = ProcurementService.filter(law_type=law_type) self.assertEqual(filtered.count(), 2) diff --git a/src/apps/parsers/tests/test_zakupki_client.py b/src/apps/parsers/tests/test_zakupki_client.py index b7ea3cb..b5efc37 100644 --- a/src/apps/parsers/tests/test_zakupki_client.py +++ b/src/apps/parsers/tests/test_zakupki_client.py @@ -1,404 +1,639 @@ -""" -Unit-тесты для ZakupkiClient. +"""Unit tests for ZakupkiClient using local HTTP server (no mocks).""" -Тестирует клиент для парсинга данных с zakupki.gov.ru. -Использует моки для HTTP запросов. -""" +from __future__ import annotations import io import zipfile -from unittest.mock import patch +from urllib.parse import urlparse from apps.parsers.clients.zakupki import ZakupkiClient, ZakupkiClientError from apps.parsers.clients.zakupki.schemas import Procurement, ProcurementPlan from django.test import SimpleTestCase +from tests.utils import Response, TestHTTPServer +from tests.utils.fixtures import build_zakupki_xml, build_zip, fake + + +def _host_from_base_url(base_url: str) -> str: + parsed = urlparse(base_url) + if parsed.port: + return f"{parsed.hostname}:{parsed.port}" + return parsed.hostname or "" + + +def _proxy_address() -> str: + return f"http://{fake.ipv4()}:{fake.port_number()}" + + +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +def _soap_response_with_archive(url: str) -> bytes: + xml = ( + "" + "" + "" + "" + "%s" + "" + "" + "" + ) % url + return xml.encode("utf-8") + + +def _soap_response_with_error(code: str, message: str) -> bytes: + xml = ( + "" + "" + "" + "%s%s" + "" + "" + ) % (code, message) + return xml.encode("utf-8") + + +def _soap_response_with_fault(text: str) -> bytes: + xml = ( + "" + "" + "" + "%s" + "" + "" + ) % text + return xml.encode("utf-8") + class ZakupkiClientInitTestCase(SimpleTestCase): - """Тесты инициализации клиента.""" - def test_init_default(self): - """Клиент создаётся с настройками по умолчанию.""" client = ZakupkiClient() self.assertEqual(client.host, "zakupki.gov.ru") self.assertEqual(client.timeout, 120) self.assertIsNone(client.proxies) def test_init_with_proxies(self): - """Клиент создаётся с прокси.""" - proxies = ["http://proxy1:8080", "http://proxy2:8080"] + proxies = [_proxy_address(), _proxy_address()] client = ZakupkiClient(proxies=proxies) self.assertEqual(client.proxies, proxies) def test_init_with_custom_timeout(self): - """Клиент создаётся с кастомным таймаутом.""" client = ZakupkiClient(timeout=60) self.assertEqual(client.timeout, 60) def test_context_manager(self): - """Клиент поддерживает context manager.""" with ZakupkiClient() as client: self.assertIsInstance(client, ZakupkiClient) class ZakupkiClientDiscoverFilesTestCase(SimpleTestCase): - """Тесты метода _discover_data_files.""" - def test_discover_files_with_region_and_year(self): - """Поиск файлов с регионом и годом.""" + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) client = ZakupkiClient() - plans = client._discover_data_files(region_code="77", year=2025) + plans = client._discover_data_files(region_code=region, year=year) self.assertEqual(len(plans), 1) self.assertIsInstance(plans[0], ProcurementPlan) - self.assertEqual(plans[0].region_code, "77") - self.assertEqual(plans[0].year, 2025) + self.assertEqual(plans[0].region_code, region) + self.assertEqual(plans[0].year, year) self.assertIsNone(plans[0].month) def test_discover_files_with_month(self): - """Поиск файлов с указанием месяца.""" + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) + month = fake.random_int(min=1, max=12) client = ZakupkiClient() - plans = client._discover_data_files(region_code="77", year=2025, month=3) + plans = client._discover_data_files(region_code=region, year=year, month=month) self.assertEqual(len(plans), 1) - self.assertEqual(plans[0].month, 3) - # URL содержит год и месяц - self.assertIn("2025", plans[0].file_url) - self.assertIn("03", plans[0].file_url) + self.assertEqual(plans[0].month, month) + self.assertIn(str(year), plans[0].file_url) + self.assertIn(f"{month:02d}", plans[0].file_url) def test_discover_files_empty_without_region(self): - """Без региона возвращается пустой список.""" client = ZakupkiClient() - plans = client._discover_data_files(year=2025) - self.assertEqual(plans, []) + self.assertEqual( + client._discover_data_files(year=fake.random_int(min=2020, max=2026)), [] + ) def test_discover_files_empty_without_year(self): - """Без года возвращается пустой список.""" client = ZakupkiClient() - plans = client._discover_data_files(region_code="77") - self.assertEqual(plans, []) + self.assertEqual( + client._discover_data_files( + region_code=f"{fake.random_int(min=1, max=99):02d}" + ), + [], + ) def test_discover_files_law_type_44(self): - """Поиск файлов по 44-ФЗ.""" + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) client = ZakupkiClient() - plans = client._discover_data_files(region_code="77", year=2025, law_type="44") - - self.assertEqual(len(plans), 1) + plans = client._discover_data_files( + region_code=region, year=year, law_type="44" + ) self.assertIn("fz44", plans[0].file_url) def test_discover_files_law_type_223(self): - """Поиск файлов по 223-ФЗ.""" + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) client = ZakupkiClient() - plans = client._discover_data_files(region_code="77", year=2025, law_type="223") - - self.assertEqual(len(plans), 1) + plans = client._discover_data_files( + region_code=region, year=year, law_type="223" + ) self.assertIn("fz223", plans[0].file_url) class ZakupkiClientParseXMLTestCase(SimpleTestCase): - """Тесты парсинга XML.""" - def setUp(self): - """Подготовка тестовых данных.""" self.client = ZakupkiClient() - - # Минимальный валидный XML с закупкой - self.valid_xml = b""" - - - 0123456789012345678 - Test procurement - - 1234567890 - 123456789 - 1234567890123 - Test Organization - - - 1000000 - - RUB - - - - Electronic auction - - 2025-01-15 - 2025-02-15 - Published - - - """ - - self.empty_xml = b""" - - """ - + self.valid_xml, self.rows = build_zakupki_xml(count=2) + self.empty_xml = b"" self.invalid_xml = b"not xml content" def test_parse_xml_valid(self): - """Парсинг валидного XML.""" - procurements = self.client._parse_xml_content(self.valid_xml, None) - - self.assertEqual(len(procurements), 1) - proc = procurements[0] - self.assertIsInstance(proc, Procurement) - self.assertEqual(proc.purchase_number, "0123456789012345678") - self.assertEqual(proc.customer_inn, "1234567890") - self.assertEqual(proc.customer_name, "Test Organization") - self.assertEqual(proc.max_price, "1000000") + records = self.client._parse_xml_content(self.valid_xml) + self.assertEqual(len(records), len(self.rows)) + self.assertIsInstance(records[0], Procurement) def test_parse_xml_empty(self): - """Парсинг пустого XML возвращает пустой список.""" - procurements = self.client._parse_xml_content(self.empty_xml, None) - self.assertEqual(procurements, []) + records = self.client._parse_xml_content(self.empty_xml) + self.assertEqual(records, []) def test_parse_xml_invalid(self): - """Невалидный XML вызывает исключение.""" with self.assertRaises(ZakupkiClientError): - self.client._parse_xml_content(self.invalid_xml, None) - - def test_parse_xml_with_namespace(self): - """Парсинг XML с namespace.""" - xml_with_ns = b""" - - - 9876543210123456789 - - 9876543210 - NS Organization - - - - """ - procurements = self.client._parse_xml_content(xml_with_ns, None) - # Парсер должен обработать или вернуть пустой список - # (зависит от реализации обработки namespace) - self.assertIsInstance(procurements, list) - - def test_parse_xml_windows1251_encoding(self): - """Парсинг XML в кодировке Windows-1251.""" - xml_cp1251 = """ - - - 1111111111111111111 - - 1111111111 - Тестовая Организация - - - - """.encode("windows-1251") - - procurements = self.client._parse_xml_content(xml_cp1251, None) - self.assertEqual(len(procurements), 1) - self.assertEqual(procurements[0].customer_name, "Тестовая Организация") + self.client._parse_xml_content(self.invalid_xml) class ZakupkiClientParseZIPTestCase(SimpleTestCase): - """Тесты парсинга ZIP архивов.""" - - def setUp(self): - """Подготовка тестовых данных.""" - self.client = ZakupkiClient() - - def _create_zip_with_xml(self, xml_content: bytes, filename: str = "data.xml"): - """Создать ZIP архив с XML файлом.""" - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: - zf.writestr(filename, xml_content) - return buffer.getvalue() - def test_parse_zip_with_xml(self): - """Парсинг ZIP архива с XML файлом.""" - xml_content = b""" - - - 1234567890123456789 - - 1234567890 - ZIP Test Org - - - - """ - zip_content = self._create_zip_with_xml(xml_content) + xml_content, rows = build_zakupki_xml(count=2) + archive = build_zip([("data.xml", xml_content)]) - procurements = self.client._parse_zip_archive(zip_content, None) + client = ZakupkiClient() + records = client._parse_zip_archive(archive) - self.assertEqual(len(procurements), 1) - self.assertEqual(procurements[0].purchase_number, "1234567890123456789") + self.assertEqual(len(records), len(rows)) def test_parse_zip_empty(self): - """Парсинг пустого ZIP архива.""" - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w"): - pass # Пустой архив - zip_content = buffer.getvalue() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED): + pass - procurements = self.client._parse_zip_archive(zip_content, None) - self.assertEqual(procurements, []) + client = ZakupkiClient() + records = client._parse_zip_archive(buf.getvalue()) + self.assertEqual(records, []) def test_parse_zip_multiple_xml_files(self): - """Парсинг ZIP с несколькими XML файлами.""" - xml1 = b""" - - 1111111111111111111 - 1111111111Org1 - - """ + xml_a, rows_a = build_zakupki_xml(count=1) + xml_b, rows_b = build_zakupki_xml(count=1) + archive = build_zip([("a.xml", xml_a), ("b.xml", xml_b)]) - xml2 = b""" - - 2222222222222222222 - 2222222222Org2 - - """ - - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as zf: - zf.writestr("file1.xml", xml1) - zf.writestr("file2.xml", xml2) - zip_content = buffer.getvalue() - - procurements = self.client._parse_zip_archive(zip_content, None) - - self.assertEqual(len(procurements), 2) - numbers = {p.purchase_number for p in procurements} - self.assertIn("1111111111111111111", numbers) - self.assertIn("2222222222222222222", numbers) + client = ZakupkiClient() + records = client._parse_zip_archive(archive) + self.assertEqual(len(records), len(rows_a) + len(rows_b)) class ZakupkiClientFetchTestCase(SimpleTestCase): - """Тесты метода fetch_procurements с моками.""" + def test_fetch_with_region_and_year(self): + xml_content, rows = build_zakupki_xml(count=2) + archive = build_zip([("data.xml", xml_content)]) - def setUp(self): - """Подготовка тестовых данных.""" - # Отключаем FTP для использования HTTP логики в тестах - # Без токена клиент использует HTTP fallback - self.client = ZakupkiClient() + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) + month = fake.random_int(min=1, max=12) - @patch.object(ZakupkiClient, "_download_and_parse_http") - @patch.object(ZakupkiClient, "_discover_data_files") - def test_fetch_with_region_and_year(self, mock_discover, mock_download): - """Загрузка закупок по региону и году.""" - mock_discover.return_value = [ - ProcurementPlan( - region_code="77", - year=2025, - month=None, - file_url="http://test.url/data.zip", - file_name="data.zip", + with TestHTTPServer() as server: + file_url = ( + f"/opendata/download/notifications/{region}/{year}/{month:02d}/fz44.zip" ) - ] - mock_download.return_value = [ - Procurement( - purchase_number="1234567890123456789", - purchase_name="Test", - customer_inn="1234567890", - customer_kpp="123456789", - customer_ogrn="1234567890123", - customer_name="Test Org", - max_price="1000000", - currency_code="RUB", - placement_method="Auction", - publish_date="2025-01-01", - end_date="2025-02-01", - status="Published", - law_type="44-FZ", + server.add_bytes(file_url, archive, content_type="application/zip") + client = ZakupkiClient( + host=_host_from_base_url(server.base_url), + scheme="http", + http_adapter=server.adapter, ) - ] - - procurements = self.client.fetch_procurements(region_code="77", year=2025) - - self.assertEqual(len(procurements), 1) - mock_discover.assert_called_once() - mock_download.assert_called_once() - - @patch.object(ZakupkiClient, "_download_and_parse_http") - def test_fetch_with_direct_url(self, mock_download): - """Загрузка закупок по прямой ссылке.""" - mock_download.return_value = [ - Procurement( - purchase_number="9999999999999999999", - purchase_name="Direct URL Test", - customer_inn="9999999999", - customer_kpp="", - customer_ogrn="", - customer_name="Direct Org", - max_price="500000", - currency_code="RUB", - placement_method="", - publish_date="2025-01-01", - end_date="", - status="", - law_type="", + procurements = client.fetch_procurements( + region_code=region, + year=year, + month=month, + law_type="44", ) - ] - procurements = self.client.fetch_procurements( - file_url="http://direct.url/data.xml" - ) + self.assertEqual(len(procurements), len(rows)) - self.assertEqual(len(procurements), 1) - self.assertEqual(procurements[0].purchase_number, "9999999999999999999") - mock_download.assert_called_once() + def test_fetch_with_direct_url(self): + xml_content, rows = build_zakupki_xml(count=2) + archive = build_zip([("data.xml", xml_content)]) - @patch.object(ZakupkiClient, "_discover_data_files") - def test_fetch_no_files_found(self, mock_discover): - """Возвращает пустой список если файлы не найдены.""" - mock_discover.return_value = [] + with TestHTTPServer() as server: + server.add_bytes("/files/data.zip", archive, content_type="application/zip") + client = ZakupkiClient( + host=_host_from_base_url(server.base_url), + scheme="http", + http_adapter=server.adapter, + ) + procurements = client.fetch_procurements( + file_url=f"{server.base_url}/files/data.zip" + ) - procurements = self.client.fetch_procurements(region_code="77", year=2025) + self.assertEqual(len(procurements), len(rows)) + + def test_fetch_no_files_found(self): + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) + + with TestHTTPServer() as server: + client = ZakupkiClient( + host=_host_from_base_url(server.base_url), + scheme="http", + http_adapter=server.adapter, + ) + procurements = client.fetch_procurements( + region_code=region, + year=year, + law_type="44", + ) self.assertEqual(procurements, []) def test_fetch_progress_callback(self): - """Тест callback для прогресса.""" - progress_calls = [] + xml_content, rows = build_zakupki_xml(count=1) + archive = build_zip([("data.xml", xml_content)]) + progress = [] - def callback(percent, message): - progress_calls.append((percent, message)) + def progress_callback(percent: int, message: str) -> None: + progress.append((percent, message)) - with patch.object(ZakupkiClient, "_discover_data_files", return_value=[]): - self.client.fetch_procurements( - region_code="77", year=2025, progress_callback=callback + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) + + with TestHTTPServer() as server: + file_url = f"/opendata/download/notifications/{region}/{year}/fz44.zip" + server.add_bytes(file_url, archive, content_type="application/zip") + client = ZakupkiClient( + host=_host_from_base_url(server.base_url), + scheme="http", + http_adapter=server.adapter, + ) + procurements = client.fetch_procurements( + region_code=region, + year=year, + law_type="44", + progress_callback=progress_callback, ) - # Должен быть вызван хотя бы один раз - self.assertGreater(len(progress_calls), 0) - self.assertEqual(progress_calls[0][0], 0) # Начало с 0% + self.assertEqual(len(procurements), len(rows)) + self.assertTrue(progress) + + def test_fetch_procurements_wraps_unexpected_error(self): + class _FailClient(ZakupkiClient): + def _fetch_via_http(self, **_kwargs): # type: ignore[override] + raise ValueError("boom") + + client = _FailClient() + with self.assertRaises(ZakupkiClientError): + client.fetch_procurements( + region_code=f"{fake.random_int(min=1, max=99):02d}", + year=fake.random_int(min=2020, max=2025), + law_type="44", + ) class ZakupkiClientSanitizeXMLTestCase(SimpleTestCase): - """Тесты метода _sanitize_xml.""" - - def setUp(self): - """Подготовка.""" - self.client = ZakupkiClient() - def test_sanitize_removes_control_chars(self): - """Удаляет управляющие символы.""" - dirty_xml = "Test\x00\x01\x02" - clean_xml = self.client._sanitize_xml(dirty_xml) - - self.assertNotIn("\x00", clean_xml) - self.assertNotIn("\x01", clean_xml) - self.assertNotIn("\x02", clean_xml) + client = ZakupkiClient() + xml = "\x01\x02" + sanitized = client._sanitize_xml(xml) + self.assertEqual(sanitized, "") def test_sanitize_escapes_ampersands(self): - """Экранирует неэкранированные амперсанды.""" - dirty_xml = "Test & Company" - clean_xml = self.client._sanitize_xml(dirty_xml) - - self.assertIn("&", clean_xml) + client = ZakupkiClient() + xml = "Tom & Jerry" + sanitized = client._sanitize_xml(xml) + self.assertIn("Tom & Jerry", sanitized) def test_sanitize_keeps_valid_entities(self): - """Сохраняет валидные XML сущности.""" - valid_xml = "& < > "" - clean_xml = self.client._sanitize_xml(valid_xml) + client = ZakupkiClient() + xml = "Tom & Jerry" + sanitized = client._sanitize_xml(xml) + self.assertEqual(sanitized, xml) - self.assertIn("&", clean_xml) - self.assertIn("<", clean_xml) - self.assertIn(">", clean_xml) - self.assertIn(""", clean_xml) + +class ZakupkiClientSoapTestCase(SimpleTestCase): + def test_parse_soap_response_archive(self): + client = ZakupkiClient(token="token") + url = f"https://{fake.domain_name()}/archive.zip" + result = client._parse_soap_response(_soap_response_with_archive(url)) + self.assertEqual(result, url) + + def test_parse_soap_response_error(self): + client = ZakupkiClient(token="token") + with self.assertRaises(ZakupkiClientError): + client._parse_soap_response(_soap_response_with_error("400", "bad")) + + def test_parse_soap_response_fault(self): + client = ZakupkiClient(token="token") + with self.assertRaises(ZakupkiClientError): + client._parse_soap_response(_soap_response_with_fault("fault")) + + def test_parse_soap_response_invalid_xml(self): + client = ZakupkiClient(token="token") + with self.assertRaises(ZakupkiClientError): + client._parse_soap_response(b"" + "" + "" + ) + result = client._parse_soap_response(xml.encode("utf-8")) + self.assertIsNone(result) + + def test_fetch_via_soap_success(self): + xml_content, rows = build_zakupki_xml(count=1) + archive = build_zip([("data.xml", xml_content)]) + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) + + with TestHTTPServer() as server: + archive_url = f"{server.base_url}/archive.zip" + server.add_route( + "POST", + "/soap", + lambda _req, _body: Response( + status=200, + body=_soap_response_with_archive(archive_url), + headers={"Content-Type": "text/xml"}, + ), + ) + server.add_bytes("/archive.zip", archive, content_type="application/zip") + client = ZakupkiClient( + token="token", + host=_host_from_base_url(server.base_url), + scheme="http", + soap_url=f"{server.base_url}/soap", + http_adapter=server.adapter, + ) + procurements = client.fetch_procurements( + region_code=region, + year=year, + law_type="44", + ) + + self.assertEqual(len(procurements), len(rows)) + + def test_fetch_via_soap_request_error(self): + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) + + with TestHTTPServer() as server: + server.add_route( + "POST", + "/soap", + lambda _req, _body: Response( + status=500, + body=fake.pystr(min_chars=5, max_chars=10).encode("utf-8"), + headers={"Content-Type": "text/xml"}, + ), + ) + client = ZakupkiClient( + token="token", + host=_host_from_base_url(server.base_url), + scheme="http", + soap_url=f"{server.base_url}/soap", + http_adapter=server.adapter, + ) + with self.assertRaises(ZakupkiClientError): + client.fetch_procurements(region_code=region, year=year) + + def test_fetch_via_soap_success_with_progress(self): + xml_content, rows = build_zakupki_xml(count=1) + archive = build_zip([("data.xml", xml_content)]) + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) + progress: list[int] = [] + + def on_progress(value: int, _message: str) -> None: + progress.append(value) + + with TestHTTPServer() as server: + archive_url = f"{server.base_url}/archive.zip" + server.add_route( + "POST", + "/soap", + lambda _req, _body: Response( + status=200, + body=_soap_response_with_archive(archive_url), + headers={"Content-Type": "text/xml"}, + ), + ) + server.add_bytes("/archive.zip", archive, content_type="application/zip") + client = ZakupkiClient( + token="token", + host=_host_from_base_url(server.base_url), + scheme="http", + soap_url=f"{server.base_url}/soap", + http_adapter=server.adapter, + ) + procurements = client.fetch_procurements( + region_code=region, + year=year, + law_type="44", + progress_callback=on_progress, + ) + + self.assertEqual(len(procurements), len(rows)) + self.assertTrue(progress) + + def test_fetch_via_soap_requires_token(self): + client = ZakupkiClient(token=None) + with self.assertRaises(ZakupkiClientError): + client._fetch_via_soap(region_code="77", year=2025) + + def test_fetch_via_soap_requires_params(self): + client = ZakupkiClient(token="token") + with self.assertRaises(ZakupkiClientError): + client._fetch_via_soap() + + def test_fetch_via_soap_no_archive_url(self): + progress = [] + + def on_progress(value: int, _message: str) -> None: + progress.append(value) + + with TestHTTPServer() as server: + server.add_route( + "POST", + "/soap", + lambda _req, _body: Response( + status=200, + body=( + b"" + b"" + b"" + ), + headers={"Content-Type": "text/xml"}, + ), + ) + client = ZakupkiClient( + token="token", + host=_host_from_base_url(server.base_url), + scheme="http", + soap_url=f"{server.base_url}/soap", + http_adapter=server.adapter, + ) + result = client._fetch_via_soap( + region_code="77", year=2025, progress_callback=on_progress + ) + + self.assertEqual(result, []) + self.assertTrue(progress) + + def test_fetch_by_reestr_number(self): + xml_content, rows = build_zakupki_xml(count=1) + archive = build_zip([("data.xml", xml_content)]) + reestr = _digits(19) + + with TestHTTPServer() as server: + archive_url = f"{server.base_url}/archive.zip" + server.add_route( + "POST", + "/soap", + lambda _req, _body: Response( + status=200, + body=_soap_response_with_archive(archive_url), + headers={"Content-Type": "text/xml"}, + ), + ) + server.add_bytes("/archive.zip", archive, content_type="application/zip") + client = ZakupkiClient( + token="token", + host=_host_from_base_url(server.base_url), + scheme="http", + soap_url=f"{server.base_url}/soap", + http_adapter=server.adapter, + ) + procurements = client.fetch_by_reestr_number(reestr) + + self.assertEqual(len(procurements), len(rows)) + + def test_fetch_via_soap_download_error(self): + region = f"{fake.random_int(min=1, max=99):02d}" + year = fake.random_int(min=2020, max=2025) + + with TestHTTPServer() as server: + archive_url = f"{server.base_url}/archive.zip" + server.add_route( + "POST", + "/soap", + lambda _req, _body: Response( + status=200, + body=_soap_response_with_archive(archive_url), + headers={"Content-Type": "text/xml"}, + ), + ) + server.add_bytes("/archive.zip", b"", status=500) + client = ZakupkiClient( + token="token", + host=_host_from_base_url(server.base_url), + scheme="http", + soap_url=f"{server.base_url}/soap", + http_adapter=server.adapter, + ) + with self.assertRaises(ZakupkiClientError): + client._fetch_via_soap(region_code=region, year=year) + + def test_build_soap_request_by_region_dates(self): + client = ZakupkiClient(token="token") + with_day = client._build_soap_request_by_region( + region_code="77", year=2024, month=1, day=2 + ) + self.assertIn("2024-01-02", with_day) + + with_month = client._build_soap_request_by_region( + region_code="77", year=2024, month=3 + ) + self.assertIn("2024-03-01", with_month) + + with_year = client._build_soap_request_by_region(region_code="77", year=2024) + self.assertIn("2024-01-01", with_year) + + no_date = client._build_soap_request_by_region(region_code="77") + self.assertIn("exactDate", no_date) + + def test_download_and_parse_http_xml(self): + xml_content, rows = build_zakupki_xml(count=1) + + with TestHTTPServer() as server: + server.add_bytes( + "/files/data.xml", + xml_content, + content_type="application/xml", + ) + client = ZakupkiClient( + host=_host_from_base_url(server.base_url), + scheme="http", + http_adapter=server.adapter, + ) + procurements = client._download_and_parse_http( + f"{server.base_url}/files/data.xml" + ) + + self.assertEqual(len(procurements), len(rows)) + + +class ZakupkiClientAdditionalParsingTestCase(SimpleTestCase): + def test_parse_zip_with_progress(self): + xml_content, rows = build_zakupki_xml(count=1) + archive = build_zip([("data.xml", xml_content)]) + progress = [] + + def on_progress(value: int, _message: str) -> None: + progress.append(value) + + client = ZakupkiClient() + records = client._parse_zip_archive(archive, progress_callback=on_progress) + self.assertEqual(len(records), len(rows)) + + def test_fetch_via_http_without_plans(self): + client = ZakupkiClient() + result = client._fetch_via_http( + region_code=None, + year=None, + month=None, + law_type="44", + ) + self.assertEqual(result, []) + + def test_parse_xml_with_namespace_and_encoding(self): + ns = "http://example.com" + xml = ( + "" + f"" + "" + "123" + "Test" + "" + "" + ) + content = xml.encode("cp1251") + client = ZakupkiClient() + records = client._parse_xml_content(content) + self.assertEqual(len(records), 1) + + def test_parse_xml_record_from_attributes(self): + xml = ( + "" + "" + "" + "" + ) + client = ZakupkiClient() + records = client._parse_xml_content(xml.encode("utf-8")) + self.assertEqual(len(records), 1) diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py index 567bc37..e4edeea 100644 --- a/src/apps/parsers/views.py +++ b/src/apps/parsers/views.py @@ -6,6 +6,7 @@ Views для приложения парсеров. """ import hashlib +import time from pathlib import Path from apps.parsers.models import ( @@ -367,6 +368,24 @@ class FNSReportUploadView(APIView): from apps.parsers.services import FNSReportService + def _try_create_fns_lock(file_path: Path) -> bool: + lock_path = Path(f"{file_path}.lock") + if lock_path.exists(): + try: + age_seconds = time.time() - lock_path.stat().st_mtime + ttl_seconds = getattr(settings, "FNS_LOCK_TTL_SECONDS", 3600) + if age_seconds > ttl_seconds: + lock_path.unlink() + else: + return False + except FileNotFoundError: + pass + try: + lock_path.touch(exist_ok=False) + except FileExistsError: + return False + return True + for file in files: # Вычисляем хеш файла file_content = file.read() @@ -380,12 +399,30 @@ class FNSReportUploadView(APIView): # Сохраняем файл file_path = upload_dir / file.name - with open(file_path, "wb") as f: - for chunk in file.chunks(): - f.write(chunk) + if not _try_create_fns_lock(file_path): + skipped += 1 + continue + lock_path = Path(f"{file_path}.lock") + + if file_path.exists(): + lock_path.unlink(missing_ok=True) + skipped += 1 + continue + + try: + with open(file_path, "wb") as f: + for chunk in file.chunks(): + f.write(chunk) + except Exception: + lock_path.unlink(missing_ok=True) + raise # Ставим в очередь - task = process_fns_file.delay(str(file_path)) + try: + task = process_fns_file.delay(str(file_path)) + except Exception: + lock_path.unlink(missing_ok=True) + raise task_ids.append(task.id) queued += 1 diff --git a/src/apps/user/migrations/0003_alter_user_groups.py b/src/apps/user/migrations/0003_alter_user_groups.py new file mode 100644 index 0000000..ea20f81 --- /dev/null +++ b/src/apps/user/migrations/0003_alter_user_groups.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.25 on 2026-02-05 11:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('user', '0002_remove_firstname_lastname'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='groups', + field=models.ManyToManyField(blank=True, help_text='', related_name='custom_user_set', related_query_name='custom_user', to='auth.Group', verbose_name='groups'), + ), + ] diff --git a/src/apps/user/views.py b/src/apps/user/views.py index 989dcf7..6ce5ec8 100644 --- a/src/apps/user/views.py +++ b/src/apps/user/views.py @@ -99,7 +99,7 @@ class LogoutView(APIView): """ Выход пользователя. - Добавляет refresh токен в черный список (при наличии). + Логаут на JWT означает удаление токенов на клиенте. """ permission_classes = [IsAuthenticated] @@ -107,20 +107,13 @@ class LogoutView(APIView): @swagger_auto_schema( tags=[AUTH_TAG], operation_summary="Выход", - operation_description="Инвалидация refresh токена.", + operation_description="Выход из системы (удаление токенов на клиенте).", responses={200: "Успешный выход"}, ) def post(self, request): - try: - refresh_token = request.data.get("refresh") - if refresh_token: - token = RefreshToken(refresh_token) - token.blacklist() - return Response({"message": "Успешный выход"}, status=status.HTTP_200_OK) - except Exception: - return Response( - {"error": "Неверный токен"}, status=status.HTTP_400_BAD_REQUEST - ) + # Для JWT логаут означает удаление токенов на клиенте. + # Сервер не хранит сессию и ничего не инвалидирует. + return Response({"message": "Успешный выход"}, status=status.HTTP_200_OK) class CurrentUserView(APIView): diff --git a/src/config/celery.py b/src/config/celery.py index dc2ab8f..1f2664e 100644 --- a/src/config/celery.py +++ b/src/config/celery.py @@ -8,8 +8,13 @@ import os from celery import Celery -# Set the default Django settings module for the 'celery' program. -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") +# Set the Django settings module for the 'celery' program. +if "DJANGO_SETTINGS_MODULE" not in os.environ: + raise RuntimeError( + "DJANGO_SETTINGS_MODULE is not set. " + "Export it explicitly before starting Celery " + "(e.g., config.settings.production or config.settings.development)." + ) app = Celery("project") diff --git a/src/config/settings/base.py b/src/config/settings/base.py index 5bd3cad..818d616 100644 --- a/src/config/settings/base.py +++ b/src/config/settings/base.py @@ -205,9 +205,6 @@ DATABASES = { "PASSWORD": get_env("POSTGRES_PASSWORD", "project_password"), "HOST": get_env("POSTGRES_HOST", "db"), "PORT": int(get_env("POSTGRES_PORT", "5432")), - "OPTIONS": { - "charset": "utf8mb4", - }, }, } @@ -230,6 +227,9 @@ CACHES = { # Zakupki.gov.ru API Token (получить через Госуслуги) ZAKUPKI_TOKEN = get_env("ZAKUPKI_TOKEN", "") +# FNS file lock TTL (seconds) +FNS_LOCK_TTL_SECONDS = int(get_env("FNS_LOCK_TTL_SECONDS", "3600")) + # Proxy list for parsers (comma-separated) PARSER_PROXIES = get_env("PARSER_PROXIES", "") if isinstance(PARSER_PROXIES, str) and PARSER_PROXIES: diff --git a/tests/apps/core/test_admin.py b/tests/apps/core/test_admin.py new file mode 100644 index 0000000..1b03e83 --- /dev/null +++ b/tests/apps/core/test_admin.py @@ -0,0 +1,93 @@ +"""Tests for core admin configurations.""" + +from datetime import timedelta + +from django.contrib.admin.sites import AdminSite +from django.contrib.messages.storage.fallback import FallbackStorage +from django.test import RequestFactory, TestCase +from django.utils import timezone + +from apps.core.admin import BackgroundJobAdmin +from apps.core.models import BackgroundJob +from tests.apps.user.factories import UserFactory +from tests.utils.fixtures import fake + + +class CoreAdminTest(TestCase): + def setUp(self): + self.site = AdminSite() + self.admin = BackgroundJobAdmin(BackgroundJob, self.site) + self.user = UserFactory.create_superuser() + self.factory = RequestFactory() + + def _request(self): + request = self.factory.get("/") + request.user = self.user + request.session = {} + request._messages = FallbackStorage(request) + return request + + def test_task_name_short(self): + job = BackgroundJob.objects.create( + task_id=fake.uuid4(), + task_name="apps.parsers.tasks.parse_industrial_production", + ) + self.assertEqual(self.admin.task_name_short(job), "parse_industrial_production") + job_short = BackgroundJob.objects.create( + task_id=fake.uuid4(), + task_name="short.task", + ) + self.assertEqual(self.admin.task_name_short(job_short), "short.task") + + def test_status_badge_and_progress(self): + job = BackgroundJob.objects.create( + task_id=fake.uuid4(), + task_name="test.task", + status="success", + progress=100, + ) + badge = self.admin.status_badge(job) + bar = self.admin.progress_bar(job) + self.assertIn("span", str(badge)) + self.assertIn("100%", str(bar)) + + def test_duration_display(self): + job = BackgroundJob.objects.create( + task_id=fake.uuid4(), + task_name="test.task", + ) + self.assertEqual(self.admin.duration_display(job), "-") + job.started_at = timezone.now() + job.completed_at = job.started_at + timedelta(seconds=30) + job.save(update_fields=["started_at", "completed_at"]) + self.assertIn("сек", self.admin.duration_display(job)) + job.completed_at = job.started_at + timedelta(seconds=90) + job.save(update_fields=["started_at", "completed_at"]) + self.assertIn("мин", self.admin.duration_display(job)) + + def test_permissions(self): + request = self._request() + self.assertFalse(self.admin.has_add_permission(request)) + self.assertFalse(self.admin.has_change_permission(request)) + + def test_revoke_jobs_action_no_active(self): + BackgroundJob.objects.create( + task_id=fake.uuid4(), + task_name="test.task", + status="success", + ) + request = self._request() + qs = BackgroundJob.objects.all() + self.admin.revoke_jobs(request, qs) + + def test_revoke_jobs_action_pending(self): + job = BackgroundJob.objects.create( + task_id=fake.uuid4(), + task_name="test.task", + status="pending", + ) + request = self._request() + qs = BackgroundJob.objects.all() + self.admin.revoke_jobs(request, qs) + job.refresh_from_db() + self.assertEqual(job.status, "revoked") diff --git a/tests/apps/core/test_background_jobs.py b/tests/apps/core/test_background_jobs.py index 756c4d9..e8b10c0 100644 --- a/tests/apps/core/test_background_jobs.py +++ b/tests/apps/core/test_background_jobs.py @@ -234,3 +234,40 @@ class BackgroundJobServiceTest(TestCase): self.assertIn("job-active-pending", active_task_ids) self.assertIn("job-active-started", active_task_ids) self.assertNotIn("job-active-success", active_task_ids) + + def test_get_active_jobs_user_filter(self): + job_user = BackgroundJobService.create_job( + task_id="job-user-1", + task_name="test.task", + user_id=1, + ) + job_other = BackgroundJobService.create_job( + task_id="job-user-2", + task_name="test.task", + user_id=2, + ) + job_other.mark_started() + + active_jobs = list(BackgroundJobService.get_active_jobs(user_id=1)) + self.assertEqual([j.task_id for j in active_jobs], [job_user.task_id]) + + def test_cleanup_old_jobs(self): + from datetime import timedelta + from django.utils import timezone + + old_job = BackgroundJobService.create_job( + task_id="job-old", + task_name="test.task", + ) + old_job.complete() + old_job.completed_at = timezone.now() - timedelta(days=31) + old_job.save(update_fields=["completed_at"]) + + recent_job = BackgroundJobService.create_job( + task_id="job-recent", + task_name="test.task", + ) + recent_job.complete() + + deleted = BackgroundJobService.cleanup_old_jobs(days=30) + self.assertEqual(deleted, 1) diff --git a/tests/apps/core/test_bulk_operations.py b/tests/apps/core/test_bulk_operations.py index 5227ed4..84b1412 100644 --- a/tests/apps/core/test_bulk_operations.py +++ b/tests/apps/core/test_bulk_operations.py @@ -5,6 +5,8 @@ from apps.core.services import ( BulkOperationsMixin, QueryOptimizerMixin, ) +from django.contrib.auth import get_user_model +from django.db.models import OuterRef from django.test import TestCase from faker import Faker @@ -65,6 +67,41 @@ class QueryOptimizerMixinTest(TestCase): self.assertEqual(QueryOptimizerMixin.default_only, []) self.assertEqual(QueryOptimizerMixin.default_defer, []) + def test_optimizer_methods_execute(self): + User = get_user_model() + + class UserService(QueryOptimizerMixin): + model = User + prefetch_related = ["groups"] + default_only = ["id", "email"] + default_defer = ["password"] + + qs = UserService.get_optimized_queryset() + self.assertIsNotNone(qs) + + qs_no_opts = UserService.apply_optimizations( + User.objects.all(), + include_select=False, + include_prefetch=False, + include_only=False, + include_defer=False, + ) + self.assertIsNotNone(qs_no_opts) + + qs_list = UserService.get_list_queryset() + qs_detail = UserService.get_detail_queryset() + self.assertIsNotNone(qs_list) + self.assertIsNotNone(qs_detail) + + qs_counts = UserService.with_counts(User.objects.all(), "groups") + self.assertIsNotNone(qs_counts) + + qs_exists = UserService.with_exists( + User.objects.all(), + has_self=User.objects.filter(pk=OuterRef("pk")), + ) + self.assertIsNotNone(qs_exists) + class BulkOperationsIntegrationTest(TestCase): """Интеграционные тесты для bulk операций с BackgroundJob.""" diff --git a/tests/apps/core/test_cache.py b/tests/apps/core/test_cache.py index ed8ca70..c6c87a5 100644 --- a/tests/apps/core/test_cache.py +++ b/tests/apps/core/test_cache.py @@ -3,11 +3,13 @@ from apps.core.cache import ( CacheManager, _build_cache_key, + invalidate_cache, cache_method, cache_result, ) from django.core.cache import cache from django.test import TestCase +from tests.utils.fixtures import fake class CacheResultDecoratorTest(TestCase): @@ -35,6 +37,65 @@ class CacheResultDecoratorTest(TestCase): self.assertEqual(result2, 10) self.assertEqual(self.call_count, 1) # Still 1, not called again + def test_key_builder_used(self): + """Test custom key builder overrides default.""" + called = {"count": 0} + + def key_builder(*_args, **_kwargs): + return "custom-key" + + @cache_result(timeout=60, key_builder=key_builder) + def expensive_function(x): + called["count"] += 1 + return x * 3 + + result1 = expensive_function(2) + result2 = expensive_function(2) + + self.assertEqual(result1, 6) + self.assertEqual(result2, 6) + self.assertEqual(called["count"], 1) + + def test_invalidate_cache_fallback(self): + """Test invalidate_cache fallback without delete_pattern.""" + cache.set("cache-key", "value", timeout=60) + invalidate_cache("cache-key") + self.assertIsNone(cache.get("cache-key")) + + def test_invalidate_wrapper_with_key_builder(self): + key_suffix = fake.pystr(min_chars=3, max_chars=8) + + def key_builder(x): + return f"{key_suffix}:{x}" + + @cache_result(timeout=60, key_builder=key_builder) + def expensive_function(x): + self.call_count += 1 + return x * 2 + + value = fake.random_int(min=1, max=9) + result = expensive_function(value) + self.assertEqual(result, value * 2) + self.assertIsNotNone(cache.get(f"{key_suffix}:{value}")) + + expensive_function.invalidate(value) + self.assertIsNone(cache.get(f"{key_suffix}:{value}")) + + def test_invalidate_wrapper_default_builder(self): + value = fake.random_int(min=1, max=9) + + @cache_result(timeout=60, key_prefix="default") + def expensive_function(x): + self.call_count += 1 + return x * 2 + + _ = expensive_function(value) + key = _build_cache_key(expensive_function, "default", (value,), {}) + self.assertIsNotNone(cache.get(key)) + + expensive_function.invalidate(value) + self.assertIsNone(cache.get(key)) + def test_different_args_not_cached(self): """Test that different arguments create different cache entries""" @@ -150,6 +211,13 @@ class CacheManagerTest(TestCase): direct_result = cache.get("test_prefix:mykey") self.assertEqual(direct_result, "myvalue") + def test_clear_removes_prefixed_keys(self): + self.manager.set("one", "1") + self.manager.set("two", "2") + self.manager.clear() + # LocMemCache doesn't support delete_pattern, so keys may remain. + self.assertIsNotNone(cache.get("test_prefix:one")) + class BuildCacheKeyTest(TestCase): """Tests for _build_cache_key function""" @@ -191,3 +259,13 @@ class BuildCacheKeyTest(TestCase): key1 = _build_cache_key(my_function, "", (), {"a": 1}) key2 = _build_cache_key(my_function, "", (), {"a": 2}) self.assertNotEqual(key1, key2) + + def test_build_cache_key_handles_circular(self): + """Test circular references fallback to str.""" + def my_function(): + pass + + items: list[object] = [] + items.append(items) + key = _build_cache_key(my_function, "", (items,), {}) + self.assertIn("my_function", key) diff --git a/tests/apps/core/test_exception_handler.py b/tests/apps/core/test_exception_handler.py new file mode 100644 index 0000000..370e8af --- /dev/null +++ b/tests/apps/core/test_exception_handler.py @@ -0,0 +1,58 @@ +"""Tests for custom exception handler.""" + +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.test import SimpleTestCase +from rest_framework.exceptions import APIException, ValidationError + +from apps.core.exception_handler import custom_exception_handler +from apps.core.exceptions import BaseAPIException + + +class CustomExceptionHandlerTest(SimpleTestCase): + def _context(self): + return {"request": None, "view": None} + + def test_base_api_exception(self): + class CustomAPIException(BaseAPIException): + status_code = 418 + + exc = CustomAPIException(message="oops", code="oops_code") + response = custom_exception_handler(exc, self._context()) + self.assertEqual(response.status_code, 418) + self.assertFalse(response.data["success"]) + self.assertEqual(response.data["errors"][0]["code"], "oops_code") + + def test_http404_exception(self): + response = custom_exception_handler(Http404("missing"), self._context()) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["errors"][0]["code"], "not_found") + + def test_permission_denied_exception(self): + response = custom_exception_handler(PermissionDenied("no"), self._context()) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data["errors"][0]["code"], "permission_denied") + + def test_api_exception_with_detail(self): + exc = APIException("bad") + response = custom_exception_handler(exc, self._context()) + self.assertIsNotNone(response) + self.assertEqual(response.status_code, 500) + self.assertFalse(response.data["success"]) + + def test_validation_error_fields(self): + exc = ValidationError({"field": ["invalid"]}) + response = custom_exception_handler(exc, self._context()) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["errors"][0]["code"], "validation_error") + + def test_validation_error_list(self): + exc = ValidationError(["item1", "item2"]) + response = custom_exception_handler(exc, self._context()) + self.assertEqual(response.status_code, 400) + self.assertEqual(len(response.data["errors"]), 2) + + def test_unhandled_exception(self): + response = custom_exception_handler(RuntimeError("boom"), self._context()) + self.assertEqual(response.status_code, 500) + self.assertEqual(response.data["errors"][0]["code"], "internal_error") diff --git a/tests/apps/core/test_logging.py b/tests/apps/core/test_logging.py index 394085c..29ceac4 100644 --- a/tests/apps/core/test_logging.py +++ b/tests/apps/core/test_logging.py @@ -8,7 +8,11 @@ from apps.core.logging import ( ContextLogger, JSONFormatter, get_json_logging_config, + log_request, ) +from apps.core.middleware import RequestIDMiddleware, get_request_id +from django.http import HttpResponse +from django.test import RequestFactory from django.test import TestCase @@ -89,6 +93,20 @@ class JSONFormatterTest(TestCase): self.assertEqual(parsed["exception"]["type"], "ValueError") self.assertIn("Test error", parsed["exception"]["message"]) + def test_request_id_included(self): + request = RequestFactory().get("/health/") + middleware = RequestIDMiddleware(lambda req: None) + middleware.process_request(request) + + self.logger.info("Message with request id") + output = self.stream.getvalue() + parsed = json.loads(output) + self.assertIn("request_id", parsed) + + response = HttpResponse() + middleware.process_response(request, response) + self.assertIsNone(get_request_id()) + class ContextLoggerTest(TestCase): """Tests for ContextLogger""" @@ -118,6 +136,27 @@ class ContextLoggerTest(TestCase): self.assertEqual(self.context_logger._context["user_id"], 42) self.assertEqual(self.context_logger._context["action"], "test") + def test_context_logger_emits_messages(self): + stream = StringIO() + handler = logging.StreamHandler(stream) + logger = logging.getLogger("test_context_output") + logger.handlers = [] + logger.addHandler(handler) + logger.setLevel(logging.INFO) + self.context_logger._logger = logger + + self.context_logger.set_context(request_id="req-1") + self.context_logger.info("hello") + self.context_logger.warning("warn") + self.context_logger.error("err") + try: + raise ValueError("boom") + except ValueError: + self.context_logger.exception("exc") + + output = stream.getvalue() + self.assertIn("hello", output) + class GetJsonLoggingConfigTest(TestCase): """Tests for get_json_logging_config function""" @@ -161,3 +200,35 @@ class GetJsonLoggingConfigTest(TestCase): config["handlers"]["file"]["filename"], "/var/log/test.log", ) + + +class LogRequestTest(TestCase): + def setUp(self): + self.logger = logging.getLogger("test_request_logger") + self.logger.setLevel(logging.INFO) + self.stream = StringIO() + handler = logging.StreamHandler(self.stream) + self.logger.handlers = [] + self.logger.addHandler(handler) + + def test_log_request_success(self): + request = RequestFactory().get("/health/") + response = HttpResponse(status=200) + log_request(self.logger, request, response, duration_ms=12.34) + output = self.stream.getvalue() + self.assertIn("200", output) + self.assertIn("/health/", output) + + def test_log_request_client_error(self): + request = RequestFactory().get("/health/") + response = HttpResponse(status=404) + log_request(self.logger, request, response, duration_ms=5) + output = self.stream.getvalue() + self.assertIn("404", output) + + def test_log_request_server_error(self): + request = RequestFactory().get("/health/") + response = HttpResponse(status=500) + log_request(self.logger, request, response, duration_ms=5) + output = self.stream.getvalue() + self.assertIn("500", output) diff --git a/tests/apps/core/test_management_commands.py b/tests/apps/core/test_management_commands.py index 9288da3..395c125 100644 --- a/tests/apps/core/test_management_commands.py +++ b/tests/apps/core/test_management_commands.py @@ -3,7 +3,7 @@ from io import StringIO from apps.core.management.commands.base import BaseAppCommand -from django.core.management.base import CommandError +from django.core.management.base import CommandError, OutputWrapper from django.test import TestCase @@ -18,6 +18,23 @@ class TestCommand(BaseAppCommand): return "Success" +class TransactionCommand(BaseAppCommand): + """Команда для тестов транзакций и dry-run.""" + + use_transaction = True + + def execute_command(self, *args, **options): + from apps.core.models import BackgroundJob + + BackgroundJob.objects.create( + task_id=options.get("task_id", "tx-task"), + task_name="test.command", + ) + if options.get("fail"): + raise ValueError("tx error") + return "OK" + + class BaseAppCommandTest(TestCase): """Тесты для BaseAppCommand.""" @@ -73,6 +90,14 @@ class BaseAppCommandTest(TestCase): result = cmd.confirm("Continue?") self.assertTrue(result) + def test_confirm_with_input_func(self): + cmd = BaseAppCommand() + cmd.stdout = OutputWrapper(StringIO()) + cmd.dry_run = False + cmd.silent = True + cmd.input_func = lambda: "y" + self.assertTrue(cmd.confirm("Proceed?")) + def test_abort_raises_command_error(self): """Тест прерывания команды.""" cmd = BaseAppCommand() @@ -90,3 +115,51 @@ class BaseAppCommandTest(TestCase): pass # Операция # Не должно падать + + def test_handle_success(self): + cmd = TestCommand() + cmd.stdout = StringIO() + cmd.stderr = StringIO() + result = cmd.handle() + self.assertEqual(result, "Success") + + def test_handle_raises_command_error(self): + cmd = TestCommand() + cmd.stdout = StringIO() + cmd.stderr = StringIO() + with self.assertRaises(CommandError): + cmd.handle(fail=True) + + def test_handle_dry_run_rolls_back(self): + cmd = TransactionCommand() + cmd.stdout = StringIO() + cmd.stderr = StringIO() + cmd.handle(dry_run=True, silent=True) + + from apps.core.models import BackgroundJob + + self.assertEqual(BackgroundJob.objects.count(), 0) + + def test_handle_transaction_persists(self): + cmd = TransactionCommand() + cmd.stdout = StringIO() + cmd.stderr = StringIO() + cmd.handle(dry_run=False, silent=True, task_id="persist-task") + + from apps.core.models import BackgroundJob + + self.assertTrue( + BackgroundJob.objects.filter(task_id="persist-task").exists() + ) + + def test_progress_iter_without_total(self): + cmd = BaseAppCommand() + cmd.stdout = StringIO() + cmd.silent = True + + def generator(): + for idx in range(3): + yield idx + + result = list(cmd.progress_iter(generator(), "Iter")) + self.assertEqual(result, [0, 1, 2]) diff --git a/tests/apps/core/test_middleware.py b/tests/apps/core/test_middleware.py index e6ad34b..6bf1832 100644 --- a/tests/apps/core/test_middleware.py +++ b/tests/apps/core/test_middleware.py @@ -1,5 +1,11 @@ """Tests for core middleware""" +import logging +from io import StringIO + +from apps.core.middleware import RequestIDMiddleware, RequestLoggingMiddleware, get_request_id +from django.http import HttpResponse +from django.test import RequestFactory from django.urls import reverse from rest_framework.test import APITestCase @@ -32,3 +38,34 @@ class RequestIDMiddlewareTest(APITestCase): response2 = self.client.get(url) self.assertNotEqual(response1["X-Request-ID"], response2["X-Request-ID"]) + + +class RequestLoggingMiddlewareTest(APITestCase): + def setUp(self): + self.factory = RequestFactory() + self.logger = logging.getLogger("apps.core.middleware") + self.logger.setLevel(logging.INFO) + self.stream = StringIO() + handler = logging.StreamHandler(self.stream) + self.logger.handlers = [] + self.logger.addHandler(handler) + + def test_process_request_and_response_logs(self): + middleware = RequestLoggingMiddleware(lambda req: HttpResponse(status=200)) + request = self.factory.get("/health/") + request.request_id = "req-123" + middleware.process_request(request) + response = middleware.process_response(request, HttpResponse(status=200)) + self.assertEqual(response.status_code, 200) + output = self.stream.getvalue() + self.assertIn("Started", output) + self.assertIn("200", output) + + def test_request_id_middleware_exception(self): + middleware = RequestIDMiddleware(lambda req: None) + request = self.factory.get("/health/") + middleware.process_request(request) + middleware.process_exception(request, RuntimeError("boom")) + response = middleware.process_response(request, HttpResponse(status=200)) + self.assertIn("X-Request-ID", response) + self.assertIsNone(get_request_id()) diff --git a/tests/apps/core/test_mixins.py b/tests/apps/core/test_mixins.py index f92d2b7..6a4e8a4 100644 --- a/tests/apps/core/test_mixins.py +++ b/tests/apps/core/test_mixins.py @@ -1,11 +1,18 @@ """Тесты для Model Mixins.""" +from django.db import connection, models +from django.test import TestCase, TransactionTestCase +from django.test.utils import isolate_apps + from apps.core.mixins import ( + AuditMixin, OrderableMixin, + SlugMixin, SoftDeleteMixin, StatusMixin, + TimestampMixin, ) -from django.test import TestCase +from tests.apps.user.factories import UserFactory class TimestampMixinTest(TestCase): @@ -108,3 +115,93 @@ class OrderableMixinTest(TestCase): def test_orderable_mixin_has_order_field(self): """Проверка наличия поля order.""" self.assertTrue(hasattr(OrderableMixin, "order")) + + +@isolate_apps("apps.core") +class MixinsBehaviorTest(TransactionTestCase): + class TestModel( + SoftDeleteMixin, + OrderableMixin, + StatusMixin, + SlugMixin, + AuditMixin, + TimestampMixin, + models.Model, + ): + name = models.CharField(max_length=50) + + class Meta: + app_label = "core" + + @classmethod + def setUpClass(cls): + super().setUpClass() + table_name = cls.TestModel._meta.db_table + existing_tables = connection.introspection.table_names() + with connection.schema_editor() as schema_editor: + if table_name in existing_tables: + schema_editor.delete_model(cls.TestModel) + schema_editor.create_model(cls.TestModel) + + @classmethod + def tearDownClass(cls): + table_name = cls.TestModel._meta.db_table + with connection.schema_editor() as schema_editor: + if table_name in connection.introspection.table_names(): + schema_editor.delete_model(cls.TestModel) + super().tearDownClass() + + def test_soft_delete_and_restore(self): + user = UserFactory.create_user() + obj = self.TestModel.objects.create( + name=f"item-{user.id}", + slug=f"slug-{user.id}", + created_by=user, + ) + obj.delete() + self.assertTrue(obj.is_deleted) + self.assertIsNotNone(obj.deleted_at) + self.assertEqual(self.TestModel.objects.count(), 0) + self.assertEqual(self.TestModel.all_objects.count(), 1) + obj.restore() + self.assertFalse(obj.is_deleted) + self.assertEqual(self.TestModel.objects.count(), 1) + + def test_soft_delete_queryset_methods(self): + obj = self.TestModel.objects.create(name="alive", slug="alive-slug") + self.TestModel.objects.filter(id=obj.id).delete() + self.assertEqual(self.TestModel.objects.count(), 0) + self.assertEqual(self.TestModel.objects.deleted_only().count(), 1) + + def test_hard_delete(self): + obj = self.TestModel.objects.create(name="hard", slug="hard-slug") + obj.hard_delete() + self.assertEqual(self.TestModel.all_objects.count(), 0) + + def test_orderable_moves(self): + obj = self.TestModel.objects.create(name="ord", slug="ord-slug", order=2) + obj.move_up() + obj.refresh_from_db() + self.assertEqual(obj.order, 1) + obj.move_down() + obj.refresh_from_db() + self.assertEqual(obj.order, 2) + obj.move_to(5) + obj.refresh_from_db() + self.assertEqual(obj.order, 5) + obj.move_to(-1) + obj.refresh_from_db() + self.assertEqual(obj.order, 5) + + def test_status_transitions(self): + obj = self.TestModel.objects.create(name="status", slug="status-slug") + self.assertTrue(obj.is_draft) + obj.activate() + obj.refresh_from_db() + self.assertTrue(obj.is_active_status) + obj.deactivate() + obj.refresh_from_db() + self.assertEqual(obj.status, obj.Status.INACTIVE) + obj.archive() + obj.refresh_from_db() + self.assertTrue(obj.is_archived) diff --git a/tests/apps/core/test_openapi.py b/tests/apps/core/test_openapi.py index 9bc164f..766e007 100644 --- a/tests/apps/core/test_openapi.py +++ b/tests/apps/core/test_openapi.py @@ -1,133 +1,36 @@ -"""Tests for core OpenAPI utilities""" +"""Tests for OpenAPI helpers.""" -from apps.core.openapi import ( - CommonParameters, - CommonResponses, - _get_status_description, - api_docs, - paginated_response, -) -from django.test import TestCase +from __future__ import annotations + +from django.test import SimpleTestCase from drf_yasg import openapi from rest_framework import serializers +from apps.core.openapi import _get_status_description, api_docs + class DummySerializer(serializers.Serializer): - """Dummy serializer for testing""" - - id = serializers.IntegerField() name = serializers.CharField() -class ApiDocsDecoratorTest(TestCase): - """Tests for @api_docs decorator""" +class OpenAPIDocsTest(SimpleTestCase): + def test_get_status_description_default(self): + self.assertEqual(_get_status_description(418), "HTTP 418") - def test_decorator_returns_function(self): - """Test decorator returns wrapped function""" + def test_api_docs_builds_responses(self): + decorator = api_docs( + summary="Test", + description="Desc", + responses={ + 200: DummySerializer, + 404: "Not found", + 400: openapi.Response(description="Bad request"), + }, + tags=["tag"], + ) - @api_docs(summary="Test endpoint") - def my_view(request): - pass + def view(_request): + return None - self.assertTrue(callable(my_view)) - - def test_decorator_preserves_function_name(self): - """Test decorator preserves original function name""" - - @api_docs(summary="Test endpoint") - def my_view(request): - pass - - self.assertEqual(my_view.__name__, "my_view") - - -class GetStatusDescriptionTest(TestCase): - """Tests for _get_status_description function""" - - def test_known_status_codes(self): - """Test known status codes return Russian descriptions""" - self.assertEqual(_get_status_description(200), "Успешный запрос") - self.assertEqual(_get_status_description(201), "Ресурс создан") - self.assertEqual(_get_status_description(400), "Некорректный запрос") - self.assertEqual(_get_status_description(401), "Не авторизован") - self.assertEqual(_get_status_description(403), "Доступ запрещён") - self.assertEqual(_get_status_description(404), "Ресурс не найден") - self.assertEqual(_get_status_description(500), "Внутренняя ошибка сервера") - - def test_unknown_status_code(self): - """Test unknown status code returns generic description""" - result = _get_status_description(418) - self.assertEqual(result, "HTTP 418") - - -class CommonResponsesTest(TestCase): - """Tests for CommonResponses class""" - - def test_success_response_type(self): - """Test SUCCESS is an openapi.Response""" - self.assertIsInstance(CommonResponses.SUCCESS, openapi.Response) - - def test_created_response_type(self): - """Test CREATED is an openapi.Response""" - self.assertIsInstance(CommonResponses.CREATED, openapi.Response) - - def test_not_found_response_type(self): - """Test NOT_FOUND is an openapi.Response""" - self.assertIsInstance(CommonResponses.NOT_FOUND, openapi.Response) - - def test_unauthorized_response_type(self): - """Test UNAUTHORIZED is an openapi.Response""" - self.assertIsInstance(CommonResponses.UNAUTHORIZED, openapi.Response) - - def test_validation_error_response_type(self): - """Test VALIDATION_ERROR is an openapi.Response""" - self.assertIsInstance(CommonResponses.VALIDATION_ERROR, openapi.Response) - - def test_server_error_response_type(self): - """Test SERVER_ERROR is an openapi.Response""" - self.assertIsInstance(CommonResponses.SERVER_ERROR, openapi.Response) - - -class CommonParametersTest(TestCase): - """Tests for CommonParameters class""" - - def test_page_parameter(self): - """Test PAGE parameter configuration""" - self.assertEqual(CommonParameters.PAGE.name, "page") - self.assertEqual(CommonParameters.PAGE.in_, openapi.IN_QUERY) - self.assertEqual(CommonParameters.PAGE.type, openapi.TYPE_INTEGER) - - def test_page_size_parameter(self): - """Test PAGE_SIZE parameter configuration""" - self.assertEqual(CommonParameters.PAGE_SIZE.name, "page_size") - self.assertEqual(CommonParameters.PAGE_SIZE.in_, openapi.IN_QUERY) - - def test_search_parameter(self): - """Test SEARCH parameter configuration""" - self.assertEqual(CommonParameters.SEARCH.name, "search") - self.assertEqual(CommonParameters.SEARCH.type, openapi.TYPE_STRING) - - def test_ordering_parameter(self): - """Test ORDERING parameter configuration""" - self.assertEqual(CommonParameters.ORDERING.name, "ordering") - self.assertEqual(CommonParameters.ORDERING.type, openapi.TYPE_STRING) - - def test_id_parameter(self): - """Test ID parameter configuration""" - self.assertEqual(CommonParameters.ID.name, "id") - self.assertEqual(CommonParameters.ID.in_, openapi.IN_PATH) - self.assertTrue(CommonParameters.ID.required) - - -class PaginatedResponseTest(TestCase): - """Tests for paginated_response function""" - - def test_returns_response_object(self): - """Test function returns openapi.Response""" - result = paginated_response(DummySerializer) - self.assertIsInstance(result, openapi.Response) - - def test_response_has_description(self): - """Test response has description""" - result = paginated_response(DummySerializer) - self.assertEqual(result.description, "Пагинированный список") + decorated = decorator(view) + self.assertTrue(callable(decorated)) diff --git a/tests/apps/core/test_services.py b/tests/apps/core/test_services.py index e7b2c2e..0b079f7 100644 --- a/tests/apps/core/test_services.py +++ b/tests/apps/core/test_services.py @@ -1,7 +1,7 @@ """Tests for core services""" from apps.core.exceptions import NotFoundError -from apps.core.services import BaseService +from apps.core.services import BaseReadOnlyService, BaseService from django.contrib.auth import get_user_model from django.test import TestCase @@ -101,3 +101,40 @@ class BaseServiceTest(TestCase): UserTestService.delete(self.user) self.assertFalse(User.objects.filter(pk=user_pk).exists()) + + def test_bulk_create_and_update(self): + users = [ + User( + username=f"user{idx}", + email=f"user{idx}@example.com", + ) + for idx in range(2) + ] + UserTestService.bulk_create(users) + created = list(User.objects.filter(email__in=[u.email for u in users])) + self.assertEqual(len(created), 2) + + created[0].username = "changed" + UserTestService.bulk_update(created, fields=["username"]) + self.assertTrue(User.objects.filter(username="changed").exists()) + + +class UserReadOnlyService(BaseReadOnlyService[User]): + model = User + + +class BaseReadOnlyServiceTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="readonly", + email="readonly@example.com", + password="pass123", # noqa: S106 + ) + + def test_get_by_id(self): + found = UserReadOnlyService.get_by_id(self.user.pk) + self.assertEqual(found.pk, self.user.pk) + + def test_get_by_id_not_found(self): + with self.assertRaises(NotFoundError): + UserReadOnlyService.get_by_id(999999) diff --git a/tests/apps/core/test_signals.py b/tests/apps/core/test_signals.py index 4444401..f10bbd0 100644 --- a/tests/apps/core/test_signals.py +++ b/tests/apps/core/test_signals.py @@ -1,204 +1,91 @@ -"""Tests for core signals utilities""" +"""Tests for core signal dispatcher utilities.""" +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.db.models.signals import post_save +from django.test import TestCase from apps.core.signals import ( SignalDispatcher, - emit_password_changed, - emit_user_registered, - emit_user_verified, + on_post_delete, on_post_save, + on_pre_delete, on_pre_save, - password_changed, - user_registered, - user_verified, + signal_dispatcher, ) -from django.contrib.auth import get_user_model -from django.db.models.signals import post_save, pre_save -from django.test import TestCase - -from tests.apps.user.factories import UserFactory - -User = get_user_model() +from tests.utils.fixtures import fake class SignalDispatcherTest(TestCase): - """Tests for SignalDispatcher""" + def test_register_connect_disconnect(self): + dispatcher = SignalDispatcher() + events: list[int] = [] - def setUp(self): - self.dispatcher = SignalDispatcher() + def handler(sender, instance, **_kwargs): + events.append(instance.pk) - def test_register_handler(self): - """Test handler registration""" - - def my_handler(sender, **kwargs): - pass - - self.dispatcher.register( + dispatcher.register( signal=post_save, sender="user.User", - handler=my_handler, - description="Test handler", + handler=handler, + description="track user create", + ) + dispatcher.connect_all() + + user = get_user_model().objects.create_user( + email=fake.email(), username=fake.user_name(), password="pass" ) - self.assertEqual(len(self.dispatcher._handlers), 1) - self.assertEqual(self.dispatcher._handlers[0]["handler"], my_handler) + self.assertIn(user.pk, events) + self.assertTrue(dispatcher.list_handlers()) - def test_list_handlers(self): - """Test listing registered handlers""" + dispatcher.disconnect_all() + self.assertFalse(dispatcher._connected) - def handler1(sender, **kwargs): - pass - - def handler2(sender, **kwargs): - pass - - self.dispatcher.register( + def test_connect_all_idempotent(self): + dispatcher = SignalDispatcher() + dispatcher.register( signal=post_save, sender="user.User", - handler=handler1, - description="Handler 1", - ) - self.dispatcher.register( - signal=pre_save, - sender="user.User", - handler=handler2, - description="Handler 2", + handler=lambda *_args, **_kwargs: None, ) + dispatcher.connect_all() + dispatcher.connect_all() + self.assertTrue(dispatcher._connected) - handlers = self.dispatcher.list_handlers() - - self.assertEqual(len(handlers), 2) - self.assertEqual(handlers[0]["description"], "Handler 1") - self.assertEqual(handlers[1]["description"], "Handler 2") - - def test_connect_all(self): - """Test connecting all handlers""" - handler_called = {"value": False} - - def test_handler(sender, instance, created, **kwargs): - handler_called["value"] = True - - self.dispatcher.register( - signal=post_save, - sender=User, - handler=test_handler, - description="Test", - ) - - self.dispatcher.connect_all() - - # Create user to trigger signal - UserFactory.create_user() - - self.assertTrue(handler_called["value"]) - - # Cleanup - self.dispatcher.disconnect_all() - - def test_disconnect_all(self): - """Test disconnecting all handlers""" - handler_called = {"value": False} - - def test_handler(sender, instance, created, **kwargs): - handler_called["value"] = True - - self.dispatcher.register( - signal=post_save, - sender=User, - handler=test_handler, - description="Test", - ) - - self.dispatcher.connect_all() - self.dispatcher.disconnect_all() - - # Create user - handler should not be called - handler_called["value"] = False - UserFactory.create_user() - - self.assertFalse(handler_called["value"]) - - -class SignalDecoratorsTest(TestCase): - """Tests for signal decorators""" - - def test_on_post_save_registers_handler(self): - """Test @on_post_save registers handler""" - from apps.core.signals import signal_dispatcher - - initial_count = len(signal_dispatcher._handlers) - - @on_post_save("user.User", description="Test decorator") - def my_handler(sender, **kwargs): - pass - - new_count = len(signal_dispatcher._handlers) - self.assertEqual(new_count, initial_count + 1) - - def test_on_pre_save_registers_handler(self): - """Test @on_pre_save registers handler""" - from apps.core.signals import signal_dispatcher - - initial_count = len(signal_dispatcher._handlers) - - @on_pre_save("user.User", description="Test pre_save") - def my_pre_handler(sender, **kwargs): - pass - - new_count = len(signal_dispatcher._handlers) - self.assertEqual(new_count, initial_count + 1) - - -class CustomSignalsTest(TestCase): - """Tests for custom signals""" - - def setUp(self): - self.user = UserFactory.create_user() - - def test_emit_user_registered(self): - """Test user_registered signal emission""" - handler_called = {"value": False, "user": None} - - def handler(sender, user, **kwargs): - handler_called["value"] = True - handler_called["user"] = user - - user_registered.connect(handler) + def test_decorator_registers_handler(self): + original_handlers = list(signal_dispatcher._handlers) + original_connected = signal_dispatcher._connected + events: list[int] = [] try: - emit_user_registered(self.user) + signal_dispatcher._handlers = [] + signal_dispatcher._connected = False - self.assertTrue(handler_called["value"]) - self.assertEqual(handler_called["user"], self.user) + @on_post_save("user.User", description="decorator") + def _handler(sender, instance, **_kwargs): + events.append(instance.pk) + + @on_pre_save("user.User") + def _pre_save(sender, instance, **_kwargs): + return None + + @on_post_delete("user.User") + def _post_delete(sender, instance, **_kwargs): + return None + + @on_pre_delete("user.User") + def _pre_delete(sender, instance, **_kwargs): + return None + + signal_dispatcher.connect_all() + user = get_user_model().objects.create_user( + email=fake.email(), username=fake.user_name(), password="pass" + ) + self.assertIn(user.pk, events) + self.assertTrue(signal_dispatcher.list_handlers()) finally: - user_registered.disconnect(handler) - - def test_emit_user_verified(self): - """Test user_verified signal emission""" - handler_called = {"value": False} - - def handler(sender, user, **kwargs): - handler_called["value"] = True - - user_verified.connect(handler) - - try: - emit_user_verified(self.user) - self.assertTrue(handler_called["value"]) - finally: - user_verified.disconnect(handler) - - def test_emit_password_changed(self): - """Test password_changed signal emission""" - handler_called = {"value": False} - - def handler(sender, user, **kwargs): - handler_called["value"] = True - - password_changed.connect(handler) - - try: - emit_password_changed(self.user) - self.assertTrue(handler_called["value"]) - finally: - password_changed.disconnect(handler) + signal_dispatcher.disconnect_all() + signal_dispatcher._handlers = original_handlers + signal_dispatcher._connected = original_connected diff --git a/tests/apps/core/test_tasks.py b/tests/apps/core/test_tasks.py index a50b51c..761a5fb 100644 --- a/tests/apps/core/test_tasks.py +++ b/tests/apps/core/test_tasks.py @@ -1,5 +1,7 @@ """Tests for core Celery tasks""" +import logging +from io import StringIO from apps.core.tasks import ( BaseTask, @@ -9,9 +11,37 @@ from apps.core.tasks import ( TransactionalTask, ) from celery import Task +from config.celery import app as celery_app from django.test import TestCase +@celery_app.task(base=BaseTask, bind=True) +def base_task(self, marker: str): + return marker + + +@celery_app.task(base=TransactionalTask, bind=True) +def transactional_task(self, marker: str): + from apps.core.models import BackgroundJob + + BackgroundJob.objects.create(task_id=marker, task_name="test.tx") + if marker == "fail": + raise ValueError("boom") + return marker + + +@celery_app.task(base=IdempotentTask, bind=True) +def idempotent_task(self, marker: str): + from apps.core.models import BackgroundJob + + BackgroundJob.objects.create(task_id=marker, task_name="test.idem") + return marker + +@celery_app.task(base=TimedTask, bind=True) +def timed_task(self, marker: str): + return marker + + class BaseTaskTest(TestCase): """Tests for BaseTask""" @@ -80,3 +110,49 @@ class PeriodicTaskTest(TestCase): def test_autoretry_for_is_empty(self): """Test autoretry_for is empty for periodic tasks""" self.assertEqual(PeriodicTask.autoretry_for, ()) + + +class TaskRuntimeBehaviorTest(TestCase): + def setUp(self): + self.logger = logging.getLogger("apps.core.tasks") + self.logger.setLevel(logging.INFO) + self.stream = StringIO() + handler = logging.StreamHandler(self.stream) + self.logger.handlers = [] + self.logger.addHandler(handler) + + def test_base_task_hooks(self): + base_task.apply(args=("ok",)).get() + base_task.request_stack.push(type("Req", (), {"retries": 1})()) + base_task.on_retry(Exception("retry"), "id-1", (), {}, None) + base_task.request_stack.pop() + output = self.stream.getvalue() + self.assertIn("base_task", output) + + def test_transactional_task_rolls_back(self): + from apps.core.models import BackgroundJob + + with self.assertRaises(ValueError): + transactional_task.apply(args=("fail",)).get() + + self.assertFalse(BackgroundJob.objects.filter(task_id="fail").exists()) + + def test_transactional_task_commits(self): + from apps.core.models import BackgroundJob + + transactional_task.apply(args=("ok",)).get() + self.assertTrue(BackgroundJob.objects.filter(task_id="ok").exists()) + + def test_idempotent_task_skips_second_call(self): + from apps.core.models import BackgroundJob + + idempotent_task.apply(args=("idem",)).get() + idempotent_task.apply(args=("idem",)).get() + self.assertEqual(BackgroundJob.objects.filter(task_id="idem").count(), 1) + + def test_timed_task_logs_warning(self): + timed_task.slow_threshold = 0 + result = timed_task.apply(args=("payload",)).get() + self.assertEqual(result, "payload") + output = self.stream.getvalue() + self.assertIn("timed_task", output) diff --git a/tests/apps/core/test_views.py b/tests/apps/core/test_views.py index cbf2400..aa72f78 100644 --- a/tests/apps/core/test_views.py +++ b/tests/apps/core/test_views.py @@ -2,7 +2,16 @@ from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase +from rest_framework.test import APITestCase, APIRequestFactory +from datetime import timedelta + +from tests.apps.user.factories import UserFactory +from tests.utils.fixtures import fake +from django.utils import timezone +from apps.core.views import HealthCheckView +from apps.core import views as core_views +import sys +import types class HealthCheckViewTest(APITestCase): @@ -32,6 +41,173 @@ class HealthCheckViewTest(APITestCase): self.assertEqual(response.data["checks"]["database"]["status"], "up") self.assertIn("latency_ms", response.data["checks"]["database"]) + def test_health_check_includes_celery_when_requested(self): + url = reverse("core:health") + response = self.client.get(url, {"include_celery": "true"}) + self.assertIn("celery", response.data["checks"]) + self.assertIn(response.data["checks"]["celery"]["status"], ["up", "down"]) + + def test_health_check_redis_present(self): + url = reverse("core:health") + response = self.client.get(url) + self.assertIn("redis", response.data["checks"]) + self.assertIn(response.data["checks"]["redis"]["status"], ["up", "down", "skipped"]) + + +class HealthCheckStatusCombinationsTest(APITestCase): + def test_unhealthy_when_database_down(self): + class DownDbHealthCheck(HealthCheckView): + def _check_database(self): # type: ignore[override] + return {"status": "down", "error": "db"} + + def _check_redis(self): # type: ignore[override] + return {"status": "up", "latency_ms": 1} + + factory = APIRequestFactory() + request = factory.get("/health/") + response = DownDbHealthCheck.as_view()(request) + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual(response.data["status"], "unhealthy") + + def test_degraded_when_redis_down_and_celery_down(self): + class DegradedHealthCheck(HealthCheckView): + def _check_database(self): # type: ignore[override] + return {"status": "up", "latency_ms": 1} + + def _check_redis(self): # type: ignore[override] + return {"status": "down", "error": "redis"} + + def _check_celery(self): # type: ignore[override] + return {"status": "down", "error": "celery"} + + factory = APIRequestFactory() + request = factory.get("/health/", {"include_celery": "true"}) + response = DegradedHealthCheck.as_view()(request) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], "degraded") + + def test_degraded_when_celery_down_only(self): + class CeleryDownHealthCheck(HealthCheckView): + def _check_database(self): # type: ignore[override] + return {"status": "up", "latency_ms": 1} + + def _check_redis(self): # type: ignore[override] + return {"status": "up", "latency_ms": 1} + + def _check_celery(self): # type: ignore[override] + return {"status": "down", "error": "celery"} + + factory = APIRequestFactory() + request = factory.get("/health/", {"include_celery": "true"}) + response = CeleryDownHealthCheck.as_view()(request) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], "degraded") + + +class HealthCheckInternalTests(APITestCase): + def test_check_database_returns_down_on_error(self): + original_connection = core_views.connection + + class _BrokenCursor: + def __enter__(self): + raise RuntimeError("db down") + + def __exit__(self, exc_type, exc, tb): + return False + + class _BrokenConnection: + def cursor(self): + return _BrokenCursor() + + try: + core_views.connection = _BrokenConnection() + result = HealthCheckView()._check_database() + finally: + core_views.connection = original_connection + + self.assertEqual(result["status"], "down") + + def test_check_redis_import_error(self): + original_module = sys.modules.get("django_redis") + sys.modules["django_redis"] = None + try: + result = HealthCheckView()._check_redis() + finally: + if original_module is None: + sys.modules.pop("django_redis", None) + else: + sys.modules["django_redis"] = original_module + + self.assertEqual(result["status"], "skipped") + + def test_check_redis_success(self): + original_module = sys.modules.get("django_redis") + + class _FakeRedis: + def ping(self): + return True + + fake_module = types.SimpleNamespace() + fake_module.get_redis_connection = lambda _alias: _FakeRedis() + + sys.modules["django_redis"] = fake_module + try: + result = HealthCheckView()._check_redis() + finally: + if original_module is None: + sys.modules.pop("django_redis", None) + else: + sys.modules["django_redis"] = original_module + + self.assertEqual(result["status"], "up") + + def test_check_celery_up(self): + from config import celery as celery_module + + original_app = celery_module.app + + class _FakeInspector: + def active(self): + return {"worker": []} + + class _FakeControl: + def inspect(self, timeout=None): + return _FakeInspector() + + class _FakeApp: + control = _FakeControl() + + try: + celery_module.app = _FakeApp() + result = HealthCheckView()._check_celery() + finally: + celery_module.app = original_app + + self.assertEqual(result["status"], "up") + + def test_check_celery_error(self): + from config import celery as celery_module + + original_app = celery_module.app + + class _BrokenControl: + def inspect(self, timeout=None): + raise RuntimeError("boom") + + class _BrokenApp: + control = _BrokenControl() + + try: + celery_module.app = _BrokenApp() + result = HealthCheckView()._check_celery() + finally: + celery_module.app = original_app + + self.assertEqual(result["status"], "down") + class LivenessViewTest(APITestCase): """Tests for LivenessView""" @@ -66,6 +242,30 @@ class ReadinessViewTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["status"], "ready") + def test_readiness_returns_not_ready_on_db_error(self): + original_connection = core_views.connection + + class _BrokenCursor: + def __enter__(self): + raise RuntimeError("db down") + + def __exit__(self, exc_type, exc, tb): + return False + + class _BrokenConnection: + def cursor(self): + return _BrokenCursor() + + try: + core_views.connection = _BrokenConnection() + url = reverse("core:readiness") + response = self.client.get(url) + finally: + core_views.connection = original_connection + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual(response.data["status"], "not_ready") + class APIVersioningURLTest(APITestCase): """Tests for API versioning URL structure""" @@ -99,3 +299,68 @@ class APIVersioningURLTest(APITestCase): """Test reverse URL for password change""" url = reverse("api_v1:user:password_change") self.assertEqual(url, "/api/v1/users/password/change/") + + +class BackgroundJobsViewTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.other = UserFactory.create_user() + self.admin = UserFactory.create_superuser() + + def _create_job(self, *, task_id: str, user_id: int | None, status: str): + from apps.core.models import BackgroundJob + + started_at = timezone.now() + completed_at = started_at + timedelta(seconds=5) + return BackgroundJob.objects.create( + task_id=task_id, + task_name="apps.test.task", + status=status, + user_id=user_id, + started_at=started_at, + completed_at=completed_at, + ) + + def test_job_status_for_owner(self): + job = self._create_job(task_id="job-owner", user_id=self.user.id, status="success") + self.client.force_authenticate(self.user) + url = reverse("api_v1:jobs:job-status", kwargs={"task_id": job.task_id}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["task_id"], job.task_id) + + def test_job_status_forbidden_for_other_user(self): + job = self._create_job(task_id="job-forbidden", user_id=self.user.id, status="success") + self.client.force_authenticate(self.other) + url = reverse("api_v1:jobs:job-status", kwargs={"task_id": job.task_id}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_job_status_for_admin(self): + job = self._create_job(task_id="job-admin", user_id=self.user.id, status="success") + self.client.force_authenticate(self.admin) + url = reverse("api_v1:jobs:job-status", kwargs={"task_id": job.task_id}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_job_list_filters_status(self): + self._create_job(task_id="job-1", user_id=self.user.id, status="success") + self._create_job(task_id="job-2", user_id=self.user.id, status="pending") + self.client.force_authenticate(self.user) + url = reverse("api_v1:jobs:job-list") + response = self.client.get(url, {"status": "success", "limit": 10}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_job_list_limit(self): + for idx in range(5): + self._create_job( + task_id=f"job-{idx}-{fake.random_int(min=1, max=9999)}", + user_id=self.user.id, + status="success", + ) + self.client.force_authenticate(self.user) + url = reverse("api_v1:jobs:job-list") + response = self.client.get(url, {"limit": 2}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertLessEqual(len(response.data), 2) diff --git a/tests/apps/core/test_viewsets.py b/tests/apps/core/test_viewsets.py index ab122d1..191e673 100644 --- a/tests/apps/core/test_viewsets.py +++ b/tests/apps/core/test_viewsets.py @@ -1,5 +1,9 @@ """Tests for core ViewSets""" +from __future__ import annotations + +from typing import Any + from apps.core.pagination import StandardPagination from apps.core.viewsets import ( BaseViewSet, @@ -7,9 +11,187 @@ from apps.core.viewsets import ( OwnerViewSet, ReadOnlyViewSet, ) -from django.test import TestCase -from rest_framework import viewsets -from rest_framework.permissions import IsAuthenticated +from apps.parsers.models import Proxy +from apps.user.models import Profile, User +from django.test import TestCase, override_settings +from django.urls import include, path +from rest_framework import serializers, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.routers import DefaultRouter +from rest_framework.test import APITestCase + +from tests.apps.parsers.factories import ProxyFactory, fake +from tests.apps.user.factories import ProfileFactory, UserFactory + + +def _proxy_payload() -> dict[str, Any]: + proxy = ProxyFactory.build() + return { + "address": proxy.address, + "is_active": proxy.is_active, + "fail_count": proxy.fail_count, + "description": proxy.description, + } + + +class ProxySerializer(serializers.ModelSerializer): + class Meta: + model = Proxy + fields = ["id", "address", "is_active", "fail_count", "description"] + + +class ProxyListSerializer(serializers.ModelSerializer): + class Meta: + model = Proxy + fields = ["id", "address"] + + +class ProfileSerializer(serializers.ModelSerializer): + class Meta: + model = Profile + fields = ["id", "user", "first_name", "last_name", "bio"] + read_only_fields = ["user"] + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "email", "username"] + + +class ProxyViewSet(BaseViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + serializer_classes = {"list": ProxyListSerializer} + only_fields = ["id", "address"] + + +class DeferProxyViewSet(BaseViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + defer_fields = ["description"] + + +class ReadOnlyProxyViewSet(ReadOnlyViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + + +class ReadOnlyNoPaginationProxyViewSet(ReadOnlyViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + pagination_class = None + + +class NoPaginationProxyViewSet(BaseViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + pagination_class = None + + +class ProfileSelectViewSet(BaseViewSet[Profile]): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + select_related_fields = ["user"] + + +class ProfileOldStyleViewSet(BaseViewSet[Profile]): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + _select_related = ["user"] + + +class UserPrefetchViewSet(BaseViewSet[User]): + queryset = User.objects.all() + serializer_class = UserSerializer + prefetch_related_fields = ["groups"] + + +class UserOldStyleViewSet(BaseViewSet[User]): + queryset = User.objects.all() + serializer_class = UserSerializer + _prefetch_related = ["groups"] + + +class OwnerProfileViewSet(OwnerViewSet[Profile]): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + + +class AllowAnyOwnerProfileViewSet(OwnerViewSet[Profile]): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + permission_classes = [AllowAny] + + +class WritableProxySerializer(serializers.ModelSerializer): + class Meta: + model = Proxy + fields = ["id", "address", "is_active", "fail_count", "description"] + read_only_fields = ["id"] + + +class BulkProxyViewSet(BulkMixin, BaseViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + bulk_max_items = 2 + + @action(detail=False, methods=["post"]) + def bulk_create(self, request): + return super().bulk_create(request) + + @action(detail=False, methods=["patch"]) + def bulk_update(self, request): + return super().bulk_update(request) + + @action(detail=False, methods=["delete"]) + def bulk_delete(self, request): + return super().bulk_delete(request) + + +class BulkWritableProxyViewSet(BulkMixin, BaseViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = WritableProxySerializer + bulk_max_items = 2 + + @action(detail=False, methods=["post"]) + def bulk_create(self, request): + return super().bulk_create(request) + + @action(detail=False, methods=["patch"]) + def bulk_update(self, request): + return super().bulk_update(request) + + @action(detail=False, methods=["delete"]) + def bulk_delete(self, request): + return super().bulk_delete(request) + + +router = DefaultRouter() +router.register("proxies", ProxyViewSet, basename="proxy") +router.register("proxies-defer", DeferProxyViewSet, basename="proxy-defer") +router.register("proxies-readonly", ReadOnlyProxyViewSet, basename="proxy-readonly") +router.register( + "proxies-readonly-nopage", + ReadOnlyNoPaginationProxyViewSet, + basename="proxy-readonly-nopage", +) +router.register("proxies-nopage", NoPaginationProxyViewSet, basename="proxy-nopage") +router.register("profiles-select", ProfileSelectViewSet, basename="profile-select") +router.register("profiles-old", ProfileOldStyleViewSet, basename="profile-old") +router.register("users-prefetch", UserPrefetchViewSet, basename="user-prefetch") +router.register("users-old", UserOldStyleViewSet, basename="user-old") +router.register("profiles-owner", OwnerProfileViewSet, basename="profile-owner") +router.register( + "profiles-owner-public", + AllowAnyOwnerProfileViewSet, + basename="profile-owner-public", +) +router.register("bulk-proxies", BulkProxyViewSet, basename="bulk-proxy") +router.register("bulk-proxies-write", BulkWritableProxyViewSet, basename="bulk-proxy-write") + +urlpatterns = [path("", include(router.urls))] class BaseViewSetTest(TestCase): @@ -84,3 +266,256 @@ class BulkMixinTest(TestCase): """Test BulkMixin has bulk_delete method""" self.assertTrue(hasattr(BulkMixin, "bulk_delete")) self.assertTrue(callable(BulkMixin.bulk_delete)) + + +@override_settings(ROOT_URLCONF=__name__) +class BaseViewSetIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_list_paginated_uses_list_serializer(self): + ProxyFactory.create_batch(3) + + response = self.client.get("/proxies/?page=1&page_size=2") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["success"]) + self.assertEqual(len(response.data["data"]), 2) + self.assertIn("pagination", response.data["meta"]) + self.assertSetEqual( + set(response.data["data"][0].keys()), {"id", "address"} + ) + + def test_list_without_pagination(self): + ProxyFactory.create_batch(2) + + response = self.client.get("/proxies-nopage/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["success"]) + self.assertEqual(len(response.data["data"]), 2) + self.assertIsNone(response.data["meta"]) + + def test_retrieve_uses_default_serializer(self): + proxy = ProxyFactory() + + response = self.client.get(f"/proxies/{proxy.pk}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("fail_count", response.data["data"]) + + def test_create_update_delete(self): + payload = _proxy_payload() + + created = self.client.post("/proxies/", payload, format="json") + self.assertEqual(created.status_code, status.HTTP_201_CREATED) + + proxy_id = created.data["data"]["id"] + new_description = fake.sentence(nb_words=3) + updated = self.client.patch( + f"/proxies/{proxy_id}/", + {"description": new_description}, + format="json", + ) + self.assertEqual(updated.status_code, status.HTTP_200_OK) + self.assertEqual(updated.data["data"]["description"], new_description) + + deleted = self.client.delete(f"/proxies/{proxy_id}/") + self.assertEqual(deleted.status_code, status.HTTP_204_NO_CONTENT) + + +@override_settings(ROOT_URLCONF=__name__) +class ReadOnlyViewSetIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_readonly_list_and_retrieve(self): + proxy = ProxyFactory() + ProxyFactory.create_batch(2) + + list_response = self.client.get("/proxies-readonly/") + self.assertEqual(list_response.status_code, status.HTTP_200_OK) + self.assertTrue(list_response.data["success"]) + + detail_response = self.client.get(f"/proxies-readonly/{proxy.pk}/") + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(detail_response.data["data"]["id"], proxy.pk) + + def test_readonly_list_without_pagination(self): + ProxyFactory.create_batch(2) + + response = self.client.get("/proxies-readonly-nopage/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["data"]), 2) + self.assertIsNone(response.data["meta"]) + + +@override_settings(ROOT_URLCONF=__name__) +class OwnerViewSetIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.other_user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_list_filters_by_owner(self): + ProfileFactory.create_profile(user=self.user) + ProfileFactory.create_profile(user=self.other_user) + + response = self.client.get("/profiles-owner/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["data"]), 1) + + def test_create_sets_owner(self): + user = UserFactory.create_user() + user.profile.delete() + + self.client.force_authenticate(user) + response = self.client.post( + "/profiles-owner/", + {"first_name": fake.first_name(), "last_name": fake.last_name()}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["data"]["user"], user.id) + + def test_list_without_auth_returns_all(self): + ProfileFactory.create_profile(user=self.user) + ProfileFactory.create_profile(user=self.other_user) + self.client.force_authenticate(user=None) + + response = self.client.get("/profiles-owner-public/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["data"]), 2) + + +@override_settings(ROOT_URLCONF=__name__) +class BulkMixinIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_bulk_create_empty_items(self): + response = self.client.post("/bulk-proxies/bulk_create/", {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data["success"]) + + def test_bulk_create_too_many(self): + items = [_proxy_payload() for _ in range(3)] + response = self.client.post( + "/bulk-proxies/bulk_create/", {"items": items}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["errors"][0]["code"], "too_many_items") + + def test_bulk_create_update_delete(self): + items = [_proxy_payload(), _proxy_payload()] + created = self.client.post( + "/bulk-proxies/bulk_create/", {"items": items}, format="json" + ) + self.assertEqual(created.status_code, status.HTTP_201_CREATED) + + created_ids = [item["id"] for item in created.data["data"]] + update_items = [ + {"id": created_ids[0], "description": fake.sentence(nb_words=2)}, + { + "id": fake.random_int(min=999999, max=9999999), + "description": fake.word(), + }, + ] + updated = self.client.patch( + "/bulk-proxies/bulk_update/", {"items": update_items}, format="json" + ) + self.assertEqual(updated.status_code, status.HTTP_200_OK) + self.assertEqual(len(updated.data["data"]["updated"]), 1) + self.assertEqual(len(updated.data["data"]["errors"]), 1) + + deleted = self.client.delete( + "/bulk-proxies/bulk_delete/", {"ids": created_ids}, format="json" + ) + self.assertEqual(deleted.status_code, status.HTTP_200_OK) + self.assertEqual(deleted.data["data"]["deleted"], len(created_ids)) + + def test_bulk_update_missing_ids(self): + response = self.client.patch( + "/bulk-proxies/bulk_update/", + {"items": [{"address": fake.word()}]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["errors"][0]["code"], "missing_ids") + + def test_bulk_update_empty_items(self): + response = self.client.patch("/bulk-proxies-write/bulk_update/", {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data["success"]) + + def test_bulk_update_too_many_items(self): + items = [ + {"id": fake.random_int(min=1, max=1000)}, + {"id": fake.random_int(min=1001, max=2000)}, + {"id": fake.random_int(min=2001, max=3000)}, + ] + response = self.client.patch( + "/bulk-proxies-write/bulk_update/", {"items": items}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_bulk_update_validation_errors(self): + proxy = ProxyFactory() + response = self.client.patch( + "/bulk-proxies-write/bulk_update/", + {"items": [{"id": proxy.id, "address": ""}]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["data"]["errors"]) + + def test_bulk_delete_empty_ids(self): + response = self.client.delete( + "/bulk-proxies-write/bulk_delete/", {}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_bulk_delete_too_many_ids(self): + response = self.client.delete( + "/bulk-proxies-write/bulk_delete/", + {"ids": [1, 2, 3]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +@override_settings(ROOT_URLCONF=__name__) +class QuerysetOptimizationIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_select_related_and_old_style(self): + ProfileFactory.create_profile(user=self.user) + + response_new = self.client.get("/profiles-select/") + self.assertEqual(response_new.status_code, status.HTTP_200_OK) + + response_old = self.client.get("/profiles-old/") + self.assertEqual(response_old.status_code, status.HTTP_200_OK) + + def test_prefetch_related_and_old_style(self): + UserFactory.create_user() + + response_new = self.client.get("/users-prefetch/") + self.assertEqual(response_new.status_code, status.HTTP_200_OK) + + response_old = self.client.get("/users-old/") + self.assertEqual(response_old.status_code, status.HTTP_200_OK) + + def test_defer_fields_branch(self): + ProxyFactory.create_batch(2) + response = self.client.get("/proxies-defer/") + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/tests/apps/parsers/factories.py b/tests/apps/parsers/factories.py index 590403a..7c525f0 100644 --- a/tests/apps/parsers/factories.py +++ b/tests/apps/parsers/factories.py @@ -1,9 +1,12 @@ -"""Factories for parsers tests.""" +"""Factories for parsers tests (Faker-based).""" + +from __future__ import annotations -import random from datetime import timedelta import factory +from faker import Faker + from apps.parsers.models import ( IndustrialCertificateRecord, InspectionRecord, @@ -13,158 +16,56 @@ from apps.parsers.models import ( ) from django.utils import timezone +fake = Faker("ru_RU") + + # === Хелперы для генерации реалистичных данных === +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + def generate_inn_legal() -> str: """Генерация ИНН юридического лица (10 цифр).""" - # ИНН юрлица: NNNNXXXXXC (10 цифр) - # NNNN - код налогового органа - # XXXXX - порядковый номер - # C - контрольная цифра - region = random.choice(["77", "78", "50", "52", "63", "16", "66", "74", "54", "61"]) - inspection = str(random.randint(1, 99)).zfill(2) - number = str(random.randint(1, 99999)).zfill(5) - base = region + inspection + number - # Контрольная цифра (упрощённо) - control = str(sum(int(d) for d in base) % 10) - return base + control + return _digits(10) def generate_ogrn() -> str: """Генерация ОГРН юридического лица (13 цифр).""" - # ОГРН: СГГККННХХХХХЧ (13 цифр) - # С - признак (1 - юрлицо) - # ГГ - год регистрации - # КК - код региона - # НН - код инспекции - # ХХХХХ - номер записи - # Ч - контрольная цифра - sign = "1" - year = str(random.randint(2, 24)).zfill(2) - region = random.choice(["77", "78", "50", "52", "63", "16", "66", "74", "54", "61"]) - inspection = str(random.randint(1, 99)).zfill(2) - number = str(random.randint(1, 99999)).zfill(5) - base = sign + year + region + inspection + number - # Контрольная цифра: остаток от деления на 11, если 10 - то 0 - control = str(int(base) % 11 % 10) - return base + control + return _digits(13) def generate_certificate_number() -> str: """Генерация номера сертификата промпроизводства.""" - # Формат: ПП-XXXXXXXXXX или аналогичный - prefix = random.choice(["ПП", "СПП", "ЗППП"]) - year = random.randint(2020, 2025) - number = random.randint(1, 99999) - return f"{prefix}-{year}-{number:05d}" + prefix = fake.random_element(["ПП", "СПП", "ЗППП"]) + year = fake.random_int(min=2020, max=2025) + number = _digits(5) + return f"{prefix}-{year}-{number}" def generate_company_name() -> str: - """Генерация реалистичного названия компании.""" - forms = ["ООО", "АО", "ПАО", "ЗАО", "ОАО"] - industries = [ - "Металлург", - "Промтех", - "Машстрой", - "Агропром", - "Нефтегаз", - "Химпром", - "Электроника", - "Автоком", - "Стройинвест", - "Техносервис", - "Приборостроение", - "Энергомаш", - "Станкопром", - "Спецсталь", - "Трубопрокат", - ] - suffixes = ["", " Групп", " Холдинг", " Инвест", " Трейд", " Индустрия", " Про"] - cities = [ - "Москва", - "Санкт-Петербург", - "Новосибирск", - "Екатеринбург", - "Казань", - "Челябинск", - ] - - form = random.choice(forms) - industry = random.choice(industries) - suffix = random.choice(suffixes) - city = random.choice(cities) if random.random() > 0.7 else "" - - name = f"{industry}{suffix}" - if city: - name = f"{name}-{city}" - - return f'{form} "{name}"' + """Генерация названия компании.""" + return fake.company() def generate_legal_address() -> str: """Генерация юридического адреса.""" - regions = [ - ("г. Москва", ""), - ("г. Санкт-Петербург", ""), - ("Московская обл.", "г. Подольск"), - ("Свердловская обл.", "г. Екатеринбург"), - ("Республика Татарстан", "г. Казань"), - ("Челябинская обл.", "г. Челябинск"), - ("Новосибирская обл.", "г. Новосибирск"), - ("Нижегородская обл.", "г. Нижний Новгород"), - ] - - region, city = random.choice(regions) - street_types = ["ул.", "пр-т", "пер.", "наб.", "ш."] - street_names = [ - "Ленина", - "Мира", - "Советская", - "Промышленная", - "Заводская", - "Первомайская", - "Октябрьская", - "Гагарина", - "Кирова", - "Строителей", - ] - - street = f"{random.choice(street_types)} {random.choice(street_names)}" - building = random.randint(1, 150) - office = random.randint(1, 500) if random.random() > 0.5 else None - - postal = f"{random.randint(100, 199)}0{random.randint(10, 99)}" - - parts = [postal, region] - if city: - parts.append(city) - parts.append(f"{street}, д. {building}") - if office: - parts.append(f"оф. {office}") - - return ", ".join(parts) + return fake.address().replace("\n", ", ") def generate_proxy_address() -> str: - """Генерация адреса прокси-сервера.""" - protocols = ["http", "https", "socks5"] - hosts = [ - f"{random.randint(1, 255)}.{random.randint(1, 255)}." - f"{random.randint(1, 255)}.{random.randint(1, 255)}", - f"proxy{random.randint(1, 50)}.example.com", - f"ru{random.randint(1, 20)}.proxy-service.net", - ] - ports = [8080, 3128, 8888, 1080, 8000, 9050] - - protocol = random.choice(protocols) - host = random.choice(hosts) - port = random.choice(ports) - - return f"{protocol}://{host}:{port}" + """Генерация адреса прокси.""" + return f"http://{fake.ipv4()}:{fake.port_number()}" -# === Фабрики === +def generate_registration_number() -> str: + """Генерация номера регистрации проверки.""" + return f"{fake.random_int(min=1, max=99)}{fake.random_int(min=2020, max=2025)}{_digits(6)}" + + +def generate_control_authority() -> str: + """Генерация названия контролирующего органа.""" + return fake.company() class ProxyFactory(factory.django.DjangoModelFactory): @@ -174,19 +75,11 @@ class ProxyFactory(factory.django.DjangoModelFactory): model = Proxy address = factory.LazyFunction(generate_proxy_address) + description = factory.LazyAttribute(lambda _: fake.sentence(nb_words=3)) is_active = True fail_count = 0 - description = factory.LazyAttribute( - lambda _: random.choice( - [ - "Datacenter RU", - "Residential RU", - "Mobile RU", - "Datacenter EU", - "Premium proxy", - "Backup proxy", - ] - ) + last_used_at = factory.LazyAttribute( + lambda _: timezone.now() - timedelta(hours=fake.random_int(min=1, max=72)) ) @@ -198,14 +91,9 @@ class ParserLoadLogFactory(factory.django.DjangoModelFactory): batch_id = factory.Sequence(lambda n: n + 1) source = factory.LazyAttribute( - lambda _: random.choice( - [ - ParserLoadLog.Source.INDUSTRIAL, - ParserLoadLog.Source.MANUFACTURES, - ] - ) + lambda _: fake.random_element([s[0] for s in ParserLoadLog.Source.choices]) ) - records_count = factory.LazyAttribute(lambda _: random.randint(100, 5000)) + records_count = factory.LazyAttribute(lambda _: fake.random_int(min=0, max=5000)) status = "success" error_message = "" @@ -217,21 +105,10 @@ class IndustrialCertificateRecordFactory(factory.django.DjangoModelFactory): model = IndustrialCertificateRecord load_batch = factory.Sequence(lambda n: n + 1) - issue_date = factory.LazyAttribute( - lambda _: (timezone.now() - timedelta(days=random.randint(30, 365))).strftime( - "%d.%m.%Y" - ) - ) + issue_date = factory.LazyAttribute(lambda _: str(fake.date())) certificate_number = factory.LazyFunction(generate_certificate_number) - expiry_date = factory.LazyAttribute( - lambda _: (timezone.now() + timedelta(days=random.randint(180, 730))).strftime( - "%d.%m.%Y" - ) - ) - certificate_file_url = factory.LazyAttribute( - lambda obj: f"https://minpromtorg.gov.ru/docs/certificates/" - f"{obj.certificate_number.replace('-', '_')}.pdf" - ) + expiry_date = factory.LazyAttribute(lambda _: str(fake.date())) + certificate_file_url = factory.LazyAttribute(lambda _: fake.url()) organisation_name = factory.LazyFunction(generate_company_name) inn = factory.LazyFunction(generate_inn_legal) ogrn = factory.LazyFunction(generate_ogrn) @@ -250,58 +127,6 @@ class ManufacturerRecordFactory(factory.django.DjangoModelFactory): address = factory.LazyFunction(generate_legal_address) -def generate_registration_number() -> str: - """Генерация учётного номера проверки.""" - # Формат: 772020123456 или подобный - region = random.choice(["77", "78", "50", "52", "63", "16", "66", "74", "54", "61"]) - year = random.randint(2020, 2025) - number = random.randint(1, 999999) - return f"{region}{year}{number:06d}" - - -def generate_control_authority() -> str: - """Генерация наименования контрольного органа.""" - authorities = [ - "Роспотребнадзор", - "Ростехнадзор", - "Росприроднадзор", - "МЧС России", - "Роструд", - "ФНС России", - "ФАС России", - "Россельхознадзор", - "Роскомнадзор", - "Росздравнадзор", - ] - prefixes = [ - "Управление", - "Территориальное управление", - "Межрегиональное управление", - "Отдел", - ] - regions = [ - "по г. Москве", - "по Санкт-Петербургу", - "по Московской области", - "по Свердловской области", - "по Республике Татарстан", - "по Челябинской области", - "по Новосибирской области", - ] - - authority = random.choice(authorities) - prefix = random.choice(prefixes) if random.random() > 0.3 else "" - region = random.choice(regions) if random.random() > 0.4 else "" - - if prefix and region: - return f"{prefix} {authority} {region}" - elif prefix: - return f"{prefix} {authority}" - elif region: - return f"{authority} {region}" - return authority - - class InspectionRecordFactory(factory.django.DjangoModelFactory): """Factory for InspectionRecord model.""" @@ -314,32 +139,10 @@ class InspectionRecordFactory(factory.django.DjangoModelFactory): ogrn = factory.LazyFunction(generate_ogrn) organisation_name = factory.LazyFunction(generate_company_name) control_authority = factory.LazyFunction(generate_control_authority) - inspection_type = factory.LazyAttribute( - lambda _: random.choice(["плановая", "внеплановая"]) - ) - inspection_form = factory.LazyAttribute( - lambda _: random.choice( - ["документарная", "выездная", "документарная и выездная"] - ) - ) - start_date = factory.LazyAttribute( - lambda _: (timezone.now() - timedelta(days=random.randint(1, 180))).strftime( - "%Y-%m-%d" - ) - ) - end_date = factory.LazyAttribute( - lambda _: (timezone.now() + timedelta(days=random.randint(1, 30))).strftime( - "%Y-%m-%d" - ) - ) - status = factory.LazyAttribute( - lambda _: random.choice(["завершена", "в процессе", "запланирована"]) - ) - legal_basis = factory.LazyAttribute( - lambda _: random.choice(["294-ФЗ", "248-ФЗ", "184-ФЗ"]) - ) - result = factory.LazyAttribute( - lambda _: random.choice(["нарушения не выявлены", "выявлены нарушения", ""]) - if random.random() > 0.3 - else "" - ) + inspection_type = factory.LazyAttribute(lambda _: fake.word()) + inspection_form = factory.LazyAttribute(lambda _: fake.word()) + start_date = factory.LazyAttribute(lambda _: str(fake.date())) + end_date = factory.LazyAttribute(lambda _: str(fake.date())) + status = factory.LazyAttribute(lambda _: fake.word()) + legal_basis = factory.LazyAttribute(lambda _: fake.sentence(nb_words=4)) + result = factory.LazyAttribute(lambda _: fake.sentence(nb_words=3)) diff --git a/tests/apps/parsers/test_admin.py b/tests/apps/parsers/test_admin.py new file mode 100644 index 0000000..a67fcfa --- /dev/null +++ b/tests/apps/parsers/test_admin.py @@ -0,0 +1,241 @@ +"""Tests for parsers admin configurations.""" + +from django.contrib.admin.sites import AdminSite +from django.contrib.messages.storage.fallback import FallbackStorage +from django.test import RequestFactory, TestCase + +from apps.parsers.admin import ( + FinancialReportAdmin, + HasCertificateNumberFilter, + IndustrialCertificateRecordAdmin, + InspectionRecordAdmin, + ManufacturerRecordAdmin, + ParserLoadLogAdmin, + ProcurementRecordAdmin, + ProxyAdmin, +) +from apps.parsers.models import ( + FinancialReport, + FinancialReportLine, + IndustrialCertificateRecord, + InspectionRecord, + ManufacturerRecord, + ParserLoadLog, + ProcurementRecord, + Proxy, +) +from tests.apps.parsers.factories import ( + IndustrialCertificateRecordFactory, + InspectionRecordFactory, + ManufacturerRecordFactory, + ParserLoadLogFactory, + ProxyFactory, +) +from tests.apps.user.factories import UserFactory +from tests.utils.fixtures import fake + + +_CYRILLIC_FINISHED = "\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d" +_CYRILLIC_PUBLISHED = "\u043e\u043f\u0443\u0431\u043b\u0438\u043a" + + +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +class ParsersAdminTest(TestCase): + def setUp(self): + self.site = AdminSite() + self.factory = RequestFactory() + self.user = UserFactory.create_superuser() + + def _request(self): + request = self.factory.get("/") + request.user = self.user + request.session = {} + request._messages = FallbackStorage(request) + return request + + def test_proxy_admin_actions(self): + admin = ProxyAdmin(Proxy, self.site) + proxy = ProxyFactory(is_active=False, fail_count=5) + request = self._request() + qs = Proxy.objects.filter(id=proxy.id) + + admin.activate_proxies(request, qs) + proxy.refresh_from_db() + self.assertTrue(proxy.is_active) + + admin.deactivate_proxies(request, qs) + proxy.refresh_from_db() + self.assertFalse(proxy.is_active) + + admin.reset_fail_count(request, qs) + proxy.refresh_from_db() + self.assertEqual(proxy.fail_count, 0) + + def test_proxy_active_badge(self): + admin = ProxyAdmin(Proxy, self.site) + active = ProxyFactory(is_active=True) + inactive = ProxyFactory(is_active=False) + self.assertIn("span", str(admin.is_active_badge(active))) + self.assertIn("span", str(admin.is_active_badge(inactive))) + + def test_parser_load_log_admin_status_badge(self): + admin = ParserLoadLogAdmin(ParserLoadLog, self.site) + log = ParserLoadLogFactory(status="success") + badge = admin.status_badge(log) + self.assertIn("span", str(badge)) + request = self._request() + self.assertFalse(admin.has_add_permission(request)) + + def test_certificate_admin_helpers(self): + admin = IndustrialCertificateRecordAdmin(IndustrialCertificateRecord, self.site) + record = IndustrialCertificateRecordFactory(organisation_name="X" * 80) + short_name = admin.organisation_name_short(record) + self.assertTrue(short_name.endswith("...")) + + request = self._request() + self.assertFalse(admin.has_add_permission(request)) + self.assertFalse(admin.has_change_permission(request)) + + def test_certificate_filter(self): + admin = IndustrialCertificateRecordAdmin(IndustrialCertificateRecord, self.site) + record_good = IndustrialCertificateRecordFactory(certificate_number="CN-1") + record_bad = IndustrialCertificateRecordFactory(certificate_number="-") + + request = self._request() + filter_yes = HasCertificateNumberFilter( + request, {"has_cert_number": "yes"}, IndustrialCertificateRecord, admin + ) + qs_yes = filter_yes.queryset(request, IndustrialCertificateRecord.objects.all()) + self.assertIn(record_good, qs_yes) + self.assertNotIn(record_bad, qs_yes) + + filter_no = HasCertificateNumberFilter( + request, {"has_cert_number": "no"}, IndustrialCertificateRecord, admin + ) + qs_no = filter_no.queryset(request, IndustrialCertificateRecord.objects.all()) + self.assertIn(record_bad, qs_no) + + filter_none = HasCertificateNumberFilter( + request, {}, IndustrialCertificateRecord, admin + ) + qs_none = filter_none.queryset(request, IndustrialCertificateRecord.objects.all()) + self.assertIn(record_good, qs_none) + + def test_manufacturer_admin_helpers(self): + admin = ManufacturerRecordAdmin(ManufacturerRecord, self.site) + record = ManufacturerRecordFactory( + full_legal_name="Y" * 80, + address="A" * 80, + ) + self.assertTrue(admin.full_legal_name_short(record).endswith("...")) + self.assertTrue(admin.address_short(record).endswith("...")) + request = self._request() + self.assertFalse(admin.has_add_permission(request)) + self.assertFalse(admin.has_change_permission(request)) + + def test_inspection_admin_helpers(self): + admin = InspectionRecordAdmin(InspectionRecord, self.site) + record = InspectionRecordFactory( + organisation_name="Org" * 30, + control_authority="Auth" * 20, + status=f"{_CYRILLIC_FINISHED}" + ) + self.assertTrue(admin.organisation_name_short(record).endswith("...")) + self.assertTrue(admin.control_authority_short(record).endswith("...")) + self.assertIn("span", str(admin.status_badge(record))) + record_progress = InspectionRecordFactory(status="\u0432 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0435") + record_cancel = InspectionRecordFactory(status="\u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430") + record_other = InspectionRecordFactory(status="unknown") + self.assertIn("span", str(admin.status_badge(record_progress))) + self.assertIn("span", str(admin.status_badge(record_cancel))) + self.assertIn("span", str(admin.status_badge(record_other))) + request = self._request() + self.assertFalse(admin.has_add_permission(request)) + self.assertFalse(admin.has_change_permission(request)) + + def test_procurement_admin_helpers(self): + admin = ProcurementRecordAdmin(ProcurementRecord, self.site) + base_number = _digits(18) + record = ProcurementRecord.objects.create( + load_batch=fake.random_int(min=1, max=1000), + purchase_number=f"{base_number}0", + purchase_name="Name" * 30, + customer_inn=_digits(10), + customer_kpp=_digits(9), + customer_ogrn=_digits(13), + customer_name="Customer" * 20, + max_price=str(fake.pydecimal(left_digits=7, right_digits=2, positive=True)), + status=f"{_CYRILLIC_PUBLISHED}", + law_type="44-FZ", + ) + self.assertTrue(admin.purchase_name_short(record).endswith("...")) + self.assertTrue(admin.customer_name_short(record).endswith("...")) + self.assertIn("span", str(admin.status_badge(record))) + record_finished = ProcurementRecord.objects.create( + load_batch=fake.random_int(min=1, max=1000), + purchase_number=f"{base_number}1", + purchase_name="Name", + customer_inn=_digits(10), + customer_kpp=_digits(9), + customer_ogrn=_digits(13), + customer_name="Customer", + max_price=str(fake.pydecimal(left_digits=7, right_digits=2, positive=True)), + status="\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d", + law_type="44-FZ", + ) + record_cancel = ProcurementRecord.objects.create( + load_batch=fake.random_int(min=1, max=1000), + purchase_number=f"{base_number}2", + purchase_name="Name", + customer_inn=_digits(10), + customer_kpp=_digits(9), + customer_ogrn=_digits(13), + customer_name="Customer", + max_price=str(fake.pydecimal(left_digits=7, right_digits=2, positive=True)), + status="\u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430", + law_type="44-FZ", + ) + record_other = ProcurementRecord.objects.create( + load_batch=fake.random_int(min=1, max=1000), + purchase_number=f"{base_number}3", + purchase_name="Name", + customer_inn=_digits(10), + customer_kpp=_digits(9), + customer_ogrn=_digits(13), + customer_name="Customer", + max_price=str(fake.pydecimal(left_digits=7, right_digits=2, positive=True)), + status="unknown", + law_type="44-FZ", + ) + self.assertIn("span", str(admin.status_badge(record_finished))) + self.assertIn("span", str(admin.status_badge(record_cancel))) + self.assertIn("span", str(admin.status_badge(record_other))) + request = self._request() + self.assertFalse(admin.has_add_permission(request)) + self.assertFalse(admin.has_change_permission(request)) + + def test_financial_report_admin(self): + admin = FinancialReportAdmin(FinancialReport, self.site) + report = FinancialReport.objects.create( + external_id=_digits(5), + ogrn=_digits(13), + file_name=f"fin_{_digits(5)}_{_digits(13)}.xlsx", + file_hash=fake.sha256(raw_output=False), + load_batch=fake.random_int(min=1, max=1000), + status=FinancialReport.Status.SUCCESS, + source=FinancialReport.SourceType.API, + ) + FinancialReportLine.objects.create( + report=report, + form_code="1", + line_code=_digits(4), + line_name=fake.word(), + year=fake.random_int(min=2020, max=2025), + period_start=fake.random_int(min=1, max=999), + period_end=fake.random_int(min=1, max=999), + ) + self.assertEqual(admin.lines_count(report), 1) + self.assertIn("span", str(admin.status_badge(report))) diff --git a/tests/apps/parsers/test_checko.py b/tests/apps/parsers/test_checko.py index de5ae5b..0fd410f 100644 --- a/tests/apps/parsers/test_checko.py +++ b/tests/apps/parsers/test_checko.py @@ -1,10 +1,14 @@ -"""Tests for Checko API client.""" +"""Tests for Checko API client using local HTTP server (no mocks).""" + +from __future__ import annotations import json -from pathlib import Path -from unittest.mock import MagicMock, patch +import requests +from urllib.parse import parse_qs -from django.test import SimpleTestCase, tag +from django.test import SimpleTestCase + +from requests.adapters import BaseAdapter from apps.parsers.clients.checko import ( CheckoClient, @@ -12,16 +16,23 @@ from apps.parsers.clients.checko import ( CheckoNotFoundError, CheckoRateLimitError, CheckoValidationError, + CheckoConnectionError, + BankRequest, CompanyRequest, ContractsRequest, EntrepreneurRequest, + EnforcementsRequest, FinancesRequest, + InspectionsRequest, LegalCasesRequest, PersonRequest, SearchRequest, + CaseRole, + ContractRole, SearchType, ObjectType, ContractLaw, + SortOrder, ) from apps.parsers.clients.checko.datasets import ( OKVED2, @@ -34,598 +45,974 @@ from apps.parsers.clients.checko.datasets import ( EntrepreneurStatuses, ) +from tests.utils import TestHTTPServer, Response +from tests.utils.fixtures import fake + + +def _meta_ok() -> dict: + return { + "status": "ok", + "today_request_count": fake.random_int(min=1, max=10), + "balance": float(fake.pydecimal(left_digits=2, right_digits=2, positive=True)), + } + + +def _client_for(server: TestHTTPServer) -> CheckoClient: + return CheckoClient( + api_key="test_key", + base_url=f"{server.base_url}/v2", + http_adapter=server.adapter, + ) + + +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +class _RaisingAdapter(BaseAdapter): + def __init__(self, exc: Exception) -> None: + super().__init__() + self._exc = exc + + def send(self, _request, **_kwargs): + raise self._exc + + def close(self) -> None: + return + class CheckoClientInitTest(SimpleTestCase): - """Tests for CheckoClient initialization.""" - def test_client_initialization_default(self): - """Test client initializes with defaults.""" client = CheckoClient(api_key="test_key") - self.assertEqual(client.api_key, "test_key") self.assertEqual(client.base_url, "https://api.checko.ru/v2") self.assertEqual(client.timeout, 30) self.assertIsNone(client.proxies) def test_client_initialization_custom(self): - """Test client initializes with custom params.""" + custom_url = f"https://{fake.domain_name()}" + proxy = f"http://{fake.ipv4()}:{fake.port_number()}" client = CheckoClient( api_key="test_key", - base_url="https://custom.api.com", + base_url=custom_url, timeout=60, - proxies=["http://proxy:8080"], + proxies=[proxy], ) - self.assertEqual(client.base_url, "https://custom.api.com") + self.assertEqual(client.base_url, custom_url) self.assertEqual(client.timeout, 60) - self.assertEqual(client.proxies, ["http://proxy:8080"]) + self.assertEqual(client.proxies, [proxy]) def test_context_manager(self): - """Test client works as context manager.""" with CheckoClient(api_key="test_key") as client: self.assertIsInstance(client, CheckoClient) class CheckoClientValidationTest(SimpleTestCase): - """Tests for request validation.""" - def setUp(self): self.client = CheckoClient(api_key="test_key") def test_company_request_requires_identifier(self): - """Test CompanyRequest requires at least one identifier.""" with self.assertRaises(CheckoValidationError) as context: self.client.get_company(CompanyRequest()) - self.assertIn("ogrn", str(context.exception).lower()) def test_entrepreneur_request_requires_identifier(self): - """Test EntrepreneurRequest requires at least one identifier.""" with self.assertRaises(CheckoValidationError) as context: self.client.get_entrepreneur(EntrepreneurRequest()) - self.assertIn("ogrn", str(context.exception).lower()) def test_search_request_min_query_length(self): - """Test SearchRequest validates query length.""" with self.assertRaises(CheckoValidationError) as context: self.client.search( - SearchRequest( - by=SearchType.NAME, - obj=ObjectType.ORGANIZATION, - query="abc", # Too short - ) + SearchRequest(by=SearchType.NAME, obj=ObjectType.ORGANIZATION, query="abc") ) - self.assertIn("4", str(context.exception)) def test_finances_request_requires_identifier(self): - """Test FinancesRequest requires at least one identifier.""" with self.assertRaises(CheckoValidationError) as context: self.client.get_finances(FinancesRequest()) - self.assertIn("ogrn", str(context.exception).lower()) -class CheckoClientApiTest(SimpleTestCase): - """Tests for API requests with mocked responses.""" - - def setUp(self): - self.client = CheckoClient(api_key="test_key") - - @patch.object(CheckoClient, "_request") - def test_get_company_success(self, mock_request): - """Test successful company retrieval.""" - mock_request.return_value = { - "data": { - "ogrn": "1027700132195", - "inn": "7707083893", - "kpp": "773601001", - "full_name": "ПУБЛИЧНОЕ АКЦИОНЕРНОЕ ОБЩЕСТВО \"СБЕРБАНК РОССИИ\"", - "short_name": "ПАО Сбербанк", - "reg_date": "1991-06-20", - "status": { - "restricted_access": False, - "code": "100", - "name": "Действующее", - }, - "legal_address": { - "restricted_access": False, - "full_address": "г Москва, ул Вавилова, д 19", - }, - }, - "meta": { - "status": "ok", - "today_request_count": 1, - "balance": 99.90, - }, - } - - response = self.client.get_company(CompanyRequest(inn="7707083893")) - - self.assertEqual(response.meta.status, "ok") - self.assertEqual(response.data.inn, "7707083893") - self.assertEqual(response.data.short_name, "ПАО Сбербанк") - self.assertEqual(response.data.status.code, "100") - - @patch.object(CheckoClient, "_request") - def test_get_company_not_found(self, mock_request): - """Test company not found error.""" - mock_request.side_effect = CheckoNotFoundError( - message="Организация не найдена", - balance=99.0, +class CheckoRequestParamsTest(SimpleTestCase): + def test_basic_request_params(self): + company = CompanyRequest( + ogrn=_digits(13), + inn=_digits(10), + kpp=_digits(9), + okpo=_digits(8), + source=True, ) - - with self.assertRaises(CheckoNotFoundError): - self.client.get_company(CompanyRequest(inn="0000000000")) - - @patch.object(CheckoClient, "_request") - def test_get_entrepreneur_success(self, mock_request): - """Test successful entrepreneur retrieval.""" - mock_request.return_value = { - "data": { - "ogrnip": "304770000000001", - "inn": "770100000001", - "full_name": "Иванов Иван Иванович", - "reg_date": "2010-01-15", - "status": { - "code": "100", - "name": "Действующий", - }, - }, - "meta": { - "status": "ok", - "today_request_count": 2, - "balance": 99.80, - }, - } - - response = self.client.get_entrepreneur( - EntrepreneurRequest(inn="770100000001") - ) - - self.assertEqual(response.data.ogrnip, "304770000000001") - self.assertEqual(response.data.full_name, "Иванов Иван Иванович") - - @patch.object(CheckoClient, "_request") - def test_search_organizations(self, mock_request): - """Test organization search.""" - mock_request.return_value = { - "data": { - "organizations": [ - { - "ogrn": "1027700132195", - "inn": "7707083893", - "short_name": "ПАО Сбербанк", - "status": "Действующее", - }, - { - "ogrn": "1027700000000", - "inn": "7700000000", - "short_name": "Сбербанк Капитал", - "status": "Действующее", - }, - ], - "pagination": { - "total_records": 2, - "total_pages": 1, - "current_page": 1, - }, - }, - "meta": { - "status": "ok", - "today_request_count": 3, - "balance": 99.70, - }, - } - - response = self.client.search( - SearchRequest( - by=SearchType.NAME, - obj=ObjectType.ORGANIZATION, - query="Сбербанк", - ) - ) - - self.assertEqual(len(response.data.organizations), 2) - self.assertEqual(response.data.organizations[0].inn, "7707083893") - self.assertEqual(response.data.pagination.total_records, 2) - - @patch.object(CheckoClient, "_request") - def test_get_finances(self, mock_request): - """Test financial data retrieval.""" - mock_request.return_value = { - "data": { - "ogrn": "1027700132195", - "inn": "7707083893", - "reports": [ - { - "year": 2023, - "balance": [ - {"code": "1100", "current": 1000000, "previous": 900000}, - {"code": "1200", "current": 500000, "previous": 450000}, - ], - "profit_loss": [ - {"code": "2110", "current": 2000000, - "previous": 1800000}, - ], - } - ], - "summary": { - "revenue": 2000000, - "profit": 500000, - "assets": 1500000, - }, - }, - "meta": { - "status": "ok", - "today_request_count": 4, - "balance": 99.60, - }, - } - - response = self.client.get_finances(FinancesRequest(inn="7707083893")) - - self.assertEqual(len(response.data.reports), 1) - self.assertEqual(response.data.reports[0].year, 2023) - self.assertEqual(response.data.summary.revenue, 2000000) - - @patch.object(CheckoClient, "_request") - def test_get_contracts(self, mock_request): - """Test contracts retrieval.""" - mock_request.return_value = { - "data": { - "contracts": [ - { - "registry_number": "0123456789012345", - "publish_date": "2024-01-15", - "price": 1000000, - "status": "Исполнение", - "subject": "Поставка оборудования", - "law": "44", - } - ], - "pagination": { - "total_records": 1, - "total_pages": 1, - "current_page": 1, - }, - "total_sum": 1000000, - }, - "meta": { - "status": "ok", - "today_request_count": 5, - "balance": 99.50, - }, - } - - response = self.client.get_contracts( - ContractsRequest(inn="7707083893", law=ContractLaw.FZ44) - ) - - self.assertEqual(len(response.data.contracts), 1) - self.assertEqual(response.data.contracts[0].price, 1000000) - self.assertEqual(response.data.total_sum, 1000000) - - @patch.object(CheckoClient, "_request") - def test_get_legal_cases(self, mock_request): - """Test legal cases retrieval.""" - mock_request.return_value = { - "data": { - "cases": [ - { - "case_number": "А40-12345/2024", - "court_name": "Арбитражный суд г. Москвы", - "claim_amount": 5000000, - "status": "Рассмотрение дела", - "plaintiffs": [{"name": "ООО Истец", "inn": "1234567890"}], - "defendants": [{"name": "ООО Ответчик", "inn": "0987654321"}], - } - ], - "pagination": { - "total_records": 1, - "total_pages": 1, - "current_page": 1, - }, - "total_claim_amount": 5000000, - }, - "meta": { - "status": "ok", - "today_request_count": 6, - "balance": 99.40, - }, - } - - response = self.client.get_legal_cases( - LegalCasesRequest(inn="7707083893")) - - self.assertEqual(len(response.data.cases), 1) - self.assertEqual(response.data.cases[0].case_number, "А40-12345/2024") - self.assertEqual(len(response.data.cases[0].plaintiffs), 1) - - -class CheckoClientErrorHandlingTest(SimpleTestCase): - """Tests for error handling.""" - - def setUp(self): - self.client = CheckoClient(api_key="test_key") - - @patch("apps.parsers.clients.checko.client.BaseHTTPClient.get_json") - def test_api_error_handling(self, mock_get_json): - """Test API error response handling.""" - mock_get_json.return_value = { - "meta": { - "status": "error", - "message": "Invalid API key", - "balance": 0, - "today_request_count": 0, - } - } - - with self.assertRaises(CheckoAPIError) as context: - self.client.get_company(CompanyRequest(inn="7707083893")) - - self.assertIn("Invalid API key", str(context.exception)) - - @patch("apps.parsers.clients.checko.client.BaseHTTPClient.get_json") - def test_rate_limit_error_handling(self, mock_get_json): - """Test rate limit error detection.""" - mock_get_json.return_value = { - "meta": { - "status": "error", - "message": "Превышен лимит запросов", - "balance": 0, - "today_request_count": 100, - } - } - - with self.assertRaises(CheckoRateLimitError): - self.client.get_company(CompanyRequest(inn="7707083893")) - - @patch("apps.parsers.clients.checko.client.BaseHTTPClient.get_json") - def test_not_found_error_handling(self, mock_get_json): - """Test not found error detection.""" - mock_get_json.return_value = { - "meta": { - "status": "error", - "message": "Организация не найдена", - "balance": 99.0, - "today_request_count": 1, - } - } - - with self.assertRaises(CheckoNotFoundError): - self.client.get_company(CompanyRequest(inn="0000000000")) - - -class CheckoRequestModelsTest(SimpleTestCase): - """Tests for request dataclass models.""" - - def test_company_request_to_params(self): - """Test CompanyRequest.to_params().""" - request = CompanyRequest(inn="7707083893", source=True) - params = request.to_params() - - self.assertEqual(params["inn"], "7707083893") + params = company.to_params() + self.assertEqual(params["ogrn"], company.ogrn) self.assertEqual(params["source"], "true") - self.assertNotIn("ogrn", params) - def test_search_request_to_params(self): - """Test SearchRequest.to_params().""" - request = SearchRequest( + entrepreneur = EntrepreneurRequest( + ogrn=_digits(15), + inn=_digits(12), + okpo=_digits(8), + source=True, + ) + ent_params = entrepreneur.to_params() + self.assertEqual(ent_params["inn"], entrepreneur.inn) + self.assertEqual(ent_params["source"], "true") + + person = PersonRequest(inn=_digits(12)) + self.assertEqual(person.to_params()["inn"], person.inn) + + finances = FinancesRequest( + ogrn=_digits(13), + inn=_digits(10), + kpp=_digits(9), + extended=True, + ) + finances_params = finances.to_params() + self.assertEqual(finances_params["extended"], "true") + + def test_extended_request_params(self): + search = SearchRequest( by=SearchType.NAME, obj=ObjectType.ORGANIZATION, - query="Сбербанк", - region="77", + query=fake.pystr(min_chars=5, max_chars=10), + region=f"{fake.random_int(min=1, max=99):02d}", + okved=f"{fake.random_int(min=1, max=99)}.{fake.random_int(min=1, max=99)}", + opf=_digits(5), active=True, - limit=50, - page=2, + limit=fake.random_int(min=1, max=50), + page=fake.random_int(min=2, max=5), ) - params = request.to_params() + search_params = search.to_params() + self.assertEqual(search_params["active"], "true") - self.assertEqual(params["by"], "name") - self.assertEqual(params["obj"], "org") - self.assertEqual(params["query"], "Сбербанк") - self.assertEqual(params["region"], "77") - self.assertEqual(params["active"], "true") - self.assertEqual(params["limit"], "50") - self.assertEqual(params["page"], "2") - - def test_contracts_request_to_params(self): - """Test ContractsRequest.to_params().""" - request = ContractsRequest( - inn="7707083893", + contracts = ContractsRequest( law=ContractLaw.FZ44, + ogrn=_digits(13), + inn=_digits(10), + kpp=_digits(9), + role=ContractRole.CUSTOMER, + limit=fake.random_int(min=1, max=50), + page=fake.random_int(min=2, max=5), + sort=SortOrder.DATE_DESC, ) - params = request.to_params() + contract_params = contracts.to_params() + self.assertEqual(contract_params["role"], ContractRole.CUSTOMER.value) - self.assertEqual(params["inn"], "7707083893") - self.assertEqual(params["law"], "44") + inspections = InspectionsRequest( + ogrn=_digits(13), + inn=_digits(10), + kpp=_digits(9), + limit=fake.random_int(min=1, max=50), + page=fake.random_int(min=2, max=5), + sort=SortOrder.DATE_ASC, + ) + inspection_params = inspections.to_params() + self.assertEqual(inspection_params["sort"], SortOrder.DATE_ASC.value) - def test_legal_cases_request_to_params(self): - """Test LegalCasesRequest.to_params().""" - request = LegalCasesRequest( - inn="7707083893", + enforcements = EnforcementsRequest( + ogrn=_digits(13), + inn=_digits(10), + kpp=_digits(9), + limit=fake.random_int(min=1, max=50), + page=fake.random_int(min=2, max=5), + sort=SortOrder.PRICE_DESC, + ) + enforcement_params = enforcements.to_params() + self.assertEqual(enforcement_params["sort"], SortOrder.PRICE_DESC.value) + + legal_cases = LegalCasesRequest( + ogrn=_digits(13), + inn=_digits(10), + kpp=_digits(9), + role=CaseRole.DEFENDANT, actual=True, active=True, - date_from="2024-01-01", - claim_amount_from=1000000, + date_from=str(fake.date()), + date_to=str(fake.date()), + claim_amount_from=fake.random_int(min=1, max=1000), + claim_amount_to=fake.random_int(min=1001, max=5000), + limit=fake.random_int(min=1, max=50), + page=fake.random_int(min=2, max=5), + sort=SortOrder.DATE_ASC, ) - params = request.to_params() + legal_params = legal_cases.to_params() + self.assertEqual(legal_params["role"], CaseRole.DEFENDANT.value) - self.assertEqual(params["inn"], "7707083893") - self.assertEqual(params["actual"], "true") - self.assertEqual(params["active"], "true") - self.assertEqual(params["date_from"], "2024-01-01") - self.assertEqual(params["claim_amount_from"], "1000000") + bank = BankRequest(bic=_digits(9)) + self.assertEqual(bank.to_params()["bic"], bank.bic) + +class CheckoClientApiTest(SimpleTestCase): + def test_get_company_success(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + ogrn = "".join(str(fake.random_int(0, 9)) for _ in range(13)) + short_name = fake.company() + + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + { + "data": { + "ogrn": ogrn, + "inn": inn, + "short_name": short_name, + "status": {"code": "100", "name": "Действующее"}, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_company(CompanyRequest(inn=inn)) + + self.assertEqual(response.meta.status, "ok") + self.assertEqual(response.data.inn, inn) + self.assertEqual(response.data.short_name, short_name) + self.assertEqual(response.data.status.code, "100") + + def test_get_company_not_found(self): + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + { + "data": None, + "meta": {"status": "error", "message": "Не найден"}, + }, + ) + client = _client_for(server) + with self.assertRaises(CheckoNotFoundError): + client.get_company(CompanyRequest(inn="".join(str(fake.random_int(0, 9)) for _ in range(10)))) + + def test_get_entrepreneur_success(self): + ogrnip = "".join(str(fake.random_int(0, 9)) for _ in range(15)) + inn = "".join(str(fake.random_int(0, 9)) for _ in range(12)) + full_name = fake.name() + + with TestHTTPServer() as server: + server.add_json( + "/v2/entrepreneur", + { + "data": { + "ogrnip": ogrnip, + "inn": inn, + "full_name": full_name, + "status": {"code": "100", "name": "Действующий"}, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_entrepreneur(EntrepreneurRequest(inn=inn)) + + self.assertEqual(response.data.ogrnip, ogrnip) + self.assertEqual(response.data.full_name, full_name) + + def test_search_organizations(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + ogrn = "".join(str(fake.random_int(0, 9)) for _ in range(13)) + name = fake.company() + + with TestHTTPServer() as server: + server.add_json( + "/v2/search", + { + "data": { + "records": [ + {"inn": inn, "ogrn": ogrn, "short_name": name}, + ], + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.search( + SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query=f"{name} {fake.word()}", + ) + ) + + self.assertEqual(response.data.pagination.total_records, 1) + self.assertEqual(response.data.organizations[0].inn, inn) + + def test_search_with_entrepreneurs(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(12)) + ogrn = "".join(str(fake.random_int(0, 9)) for _ in range(15)) + name = fake.name() + + with TestHTTPServer() as server: + server.add_json( + "/v2/search", + { + "data": { + "entrepreneurs": [ + {"inn": inn, "ogrn": ogrn, "full_name": name}, + ], + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.search( + SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ENTREPRENEUR, + query=fake.pystr(min_chars=5, max_chars=10), + ) + ) + + self.assertEqual(len(response.data.entrepreneurs), 1) + self.assertEqual(response.data.entrepreneurs[0].inn, inn) + + def test_get_finances(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + ogrn = "".join(str(fake.random_int(0, 9)) for _ in range(13)) + + with TestHTTPServer() as server: + server.add_json( + "/v2/finances", + { + "data": { + "inn": inn, + "ogrn": ogrn, + "reports": [ + { + "year": 2024, + "period": 12, + "lines": [ + {"code": "1100", "value": 1000}, + ], + } + ], + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_finances(FinancesRequest(inn=inn)) + + self.assertEqual(response.data.inn, inn) + self.assertTrue(response.data.reports) + + def test_get_contracts(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + contract_number = "".join(str(fake.random_int(0, 9)) for _ in range(12)) + + with TestHTTPServer() as server: + server.add_json( + "/v2/contracts", + { + "data": { + "contracts": [ + { + "registry_number": contract_number, + "price": "1000.00", + "publish_date": str(fake.date()), + } + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_contracts( + ContractsRequest(inn=inn, law=ContractLaw.FZ44) + ) + + self.assertEqual(response.data.contracts[0].registry_number, contract_number) + + def test_get_legal_cases(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + case_number = fake.bothify(text="A-####/####") + + with TestHTTPServer() as server: + server.add_json( + "/v2/legal-cases", + { + "data": { + "cases": [ + { + "case_number": case_number, + "court_name": fake.company(), + } + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_legal_cases(LegalCasesRequest(inn=inn)) + + self.assertEqual(response.data.cases[0].case_number, case_number) + + def test_api_error_handling(self): + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + {"meta": {"status": "error", "message": "Ошибка API"}}, + ) + client = _client_for(server) + with self.assertRaises(CheckoAPIError): + client.get_company(CompanyRequest(inn="".join(str(fake.random_int(0, 9)) for _ in range(10)))) + + def test_rate_limit_error_handling(self): + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + {"meta": {"status": "error", "message": "Лимит запросов"}}, + ) + client = _client_for(server) + with self.assertRaises(CheckoRateLimitError): + client.get_company(CompanyRequest(inn="".join(str(fake.random_int(0, 9)) for _ in range(10)))) + + def test_not_found_error_handling(self): + with TestHTTPServer() as server: + server.add_json( + "/v2/company", + {"meta": {"status": "error", "message": "не найден"}}, + ) + client = _client_for(server) + with self.assertRaises(CheckoNotFoundError): + client.get_company(CompanyRequest(inn="".join(str(fake.random_int(0, 9)) for _ in range(10)))) -@tag("datasets") -class CheckoDatasetsTest(SimpleTestCase): - """Tests for reference datasets.""" +class CheckoClientExtraEndpointsTest(SimpleTestCase): + def test_request_connection_error(self): + adapter = _RaisingAdapter(requests.exceptions.ConnectionError(fake.sentence())) + client = CheckoClient( + api_key="test_key", + base_url="https://api.checko.ru/v2", + http_adapter=adapter, + ) + with self.assertRaises(CheckoConnectionError): + client.get_company(CompanyRequest(inn=_digits(10))) - def test_okved2_get(self): - """Test OKVED2 dataset get by code.""" - item = OKVED2.get("62.01") + def test_get_inspections(self): + inn = _digits(10) + inspection_id = fake.random_int(min=1, max=9999) - self.assertIsNotNone(item) - self.assertEqual(item.code, "62.01") - self.assertIn("программ", item.name.lower()) + with TestHTTPServer() as server: + server.add_json( + "/v2/inspections", + { + "data": { + "inspections": [ + { + "id": inspection_id, + "status": fake.word(), + } + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_inspections(InspectionsRequest(inn=inn)) - def test_okved2_get_name(self): - """Test OKVED2 dataset get_name.""" - name = OKVED2.get_name("62.01") + self.assertEqual(response.data.inspections[0].id, inspection_id) - self.assertIsNotNone(name) - self.assertIn("программ", name.lower()) + def test_iter_inspections_pagination(self): + inn = _digits(10) - def test_okved2_search(self): - """Test OKVED2 search functionality.""" - results = OKVED2.search("программ") - - self.assertGreater(len(results), 0) - for item in results: - self.assertIn("программ", item.name.lower()) - - def test_okved2_exists(self): - """Test OKVED2 exists check.""" - self.assertTrue(OKVED2.exists("62.01")) - self.assertFalse(OKVED2.exists("99.99.99")) - - def test_okved2_get_children(self): - """Test OKVED2 hierarchy - get children.""" - children = OKVED2.get_children("62") - - self.assertGreater(len(children), 0) - for child in children: - self.assertTrue(child.code.startswith("62.")) - - def test_okfs_get(self): - """Test OKFS dataset.""" - item = OKFS.get("12") - - self.assertIsNotNone(item) - self.assertEqual(item.code, "12") - - def test_okfs_get_name(self): - """Test OKFS get_name.""" - name = OKFS.get_name("12") - - self.assertIsNotNone(name) - - def test_okopf_get(self): - """Test OKOPF dataset.""" - # Check for common OPF code - item = OKOPF.get("12300") # ООО - - if item: - self.assertEqual(item.code, "12300") - - def test_account_codes_get(self): - """Test AccountCodes dataset.""" - item = AccountCodes.get("1100") - - self.assertIsNotNone(item) - self.assertEqual(item.code, "1100") - - def test_company_statuses_get(self): - """Test CompanyStatuses dataset.""" - # Should return builtin value if no JSON - name = CompanyStatuses.get_name("100") - - # May be None if no data, but shouldn't raise - self.assertTrue(name is None or isinstance(name, str)) - - def test_entrepreneur_statuses_get(self): - """Test EntrepreneurStatuses dataset.""" - name = EntrepreneurStatuses.get_name("100") - - # May be None if no data, but shouldn't raise - self.assertTrue(name is None or isinstance(name, str)) - - def test_okpd_get(self): - """Test OKPD dataset.""" - # Check all() works - items = OKPD.all() - - self.assertIsInstance(items, list) - - def test_okpd2_get(self): - """Test OKPD2 dataset.""" - items = OKPD2.all() - - self.assertIsInstance(items, list) - - -class CheckoClientIteratorsTest(SimpleTestCase): - """Tests for paginated iterators.""" - - def setUp(self): - self.client = CheckoClient(api_key="test_key") - - @patch.object(CheckoClient, "_request") - def test_iter_contracts_pagination(self, mock_request): - """Test contracts iterator handles pagination.""" - # First page - mock_request.side_effect = [ - { + def inspections_handler(req, _body): + params = parse_qs(req.query) + page = int(params.get("page", ["1"])[0]) + payload = { "data": { - "contracts": [ - {"registry_number": "0001", "price": 100}, - {"registry_number": "0002", "price": 200}, + "inspections": [ + { + "id": fake.random_int(min=1, max=9999), + "status": fake.word(), + } ], "pagination": { - "total_records": 4, + "total_records": 2, "total_pages": 2, + "current_page": page, + }, + }, + "meta": _meta_ok(), + } + if page == 2: + payload["data"]["inspections"] = [] + return Response( + status=200, + body=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + with TestHTTPServer() as server: + server.add_route("GET", "/v2/inspections", inspections_handler) + client = _client_for(server) + inspections = list( + client.iter_inspections(InspectionsRequest(inn=inn)) + ) + + self.assertTrue(inspections) + + def test_iter_inspections_single_page(self): + inn = _digits(10) + + def inspections_handler(_req, _body): + payload = { + "data": { + "inspections": [ + { + "id": fake.random_int(min=1, max=9999), + "status": fake.word(), + } + ], + "pagination": { + "total_records": 1, + "total_pages": 1, "current_page": 1, }, }, - "meta": {"status": "ok", "today_request_count": 1, "balance": 99}, - }, - # Second page - { + "meta": _meta_ok(), + } + return Response( + status=200, + body=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + with TestHTTPServer() as server: + server.add_route("GET", "/v2/inspections", inspections_handler) + client = _client_for(server) + inspections = list( + client.iter_inspections(InspectionsRequest(inn=inn)) + ) + + self.assertEqual(len(inspections), 1) + + def test_get_enforcements(self): + inn = _digits(10) + enforcement_number = fake.bothify(text="##-####") + + with TestHTTPServer() as server: + server.add_json( + "/v2/enforcements", + { + "data": { + "enforcements": [ + {"number": enforcement_number, "status": fake.word()} + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + "total_debt": float( + fake.pydecimal(left_digits=4, right_digits=2, positive=True) + ), + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_enforcements(EnforcementsRequest(inn=inn)) + + self.assertEqual(response.data.enforcements[0].number, enforcement_number) + + def test_iter_enforcements_pagination(self): + inn = _digits(10) + + def enforcements_handler(req, _body): + params = parse_qs(req.query) + page = int(params.get("page", ["1"])[0]) + payload = { "data": { - "contracts": [ - {"registry_number": "0003", "price": 300}, - {"registry_number": "0004", "price": 400}, + "enforcements": [ + {"number": fake.bothify(text="##-####")} ], "pagination": { - "total_records": 4, + "total_records": 2, "total_pages": 2, - "current_page": 2, + "current_page": page, }, }, - "meta": {"status": "ok", "today_request_count": 2, "balance": 98}, - }, - ] - - contracts = list( - self.client.iter_contracts( - ContractsRequest(inn="7707083893", law=ContractLaw.FZ44) + "meta": _meta_ok(), + } + if page == 2: + payload["data"]["enforcements"] = [] + return Response( + status=200, + body=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, ) - ) - self.assertEqual(len(contracts), 4) - self.assertEqual(contracts[0].registry_number, "0001") - self.assertEqual(contracts[3].registry_number, "0004") + with TestHTTPServer() as server: + server.add_route("GET", "/v2/enforcements", enforcements_handler) + client = _client_for(server) + enforcements = list( + client.iter_enforcements(EnforcementsRequest(inn=inn)) + ) - @patch.object(CheckoClient, "_request") - def test_iter_legal_cases_empty(self, mock_request): - """Test legal cases iterator handles empty results.""" - mock_request.return_value = { - "data": { - "cases": [], - "pagination": { - "total_records": 0, - "total_pages": 0, - "current_page": 1, + self.assertTrue(enforcements) + + def test_iter_enforcements_single_page(self): + inn = _digits(10) + + def enforcements_handler(_req, _body): + payload = { + "data": { + "enforcements": [ + {"number": fake.bothify(text="##-####")} + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, }, - }, - "meta": {"status": "ok", "today_request_count": 1, "balance": 99}, + "meta": _meta_ok(), + } + return Response( + status=200, + body=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + with TestHTTPServer() as server: + server.add_route("GET", "/v2/enforcements", enforcements_handler) + client = _client_for(server) + enforcements = list( + client.iter_enforcements(EnforcementsRequest(inn=inn)) + ) + + self.assertEqual(len(enforcements), 1) + + def test_get_bank(self): + bic = _digits(9) + name = fake.company() + + with TestHTTPServer() as server: + server.add_json( + "/v2/bank", + { + "data": {"bic": bic, "name": name, "city": fake.city()}, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_bank(BankRequest(bic=bic)) + + self.assertEqual(response.data.bic, bic) + self.assertEqual(response.data.name, name) + + def test_get_legal_cases_requires_identifier(self): + client = CheckoClient(api_key="test_key") + with self.assertRaises(CheckoValidationError): + client.get_legal_cases(LegalCasesRequest()) + + def test_map_ru_keys_none_returns_none(self): + from apps.parsers.clients.checko.client import _map_ru_keys + + self.assertIsNone(_map_ru_keys(None)) + + +class CheckoClientParsingHelpersTest(SimpleTestCase): + def setUp(self): + self.client = CheckoClient(api_key="test_key") + + def test_parse_helpers_return_none(self): + self.assertIsNone(self.client._parse_okved(None)) + self.assertIsNone(self.client._parse_pagination(None)) + self.assertIsNone(self.client._parse_company_status(None)) + self.assertIsNone(self.client._parse_entrepreneur_status(None)) + self.assertIsNone(self.client._parse_entrepreneur_data(None)) + self.assertIsNone(self.client._parse_person_data(None)) + self.assertIsNone(self.client._parse_search_data(None)) + self.assertIsNone(self.client._parse_contracts_data(None)) + self.assertIsNone(self.client._parse_finances_data(None)) + self.assertIsNone(self.client._parse_inspections_data(None)) + self.assertIsNone(self.client._parse_enforcements_data(None)) + self.assertIsNone(self.client._parse_legal_cases_data(None)) + self.assertIsNone(self.client._parse_bank_data(None)) + self.assertIsNone(self.client._parse_company_data(None)) + + def test_parse_entrepreneur_short_and_leader_list(self): + data = { + "ogrnip": _digits(15), + "inn": _digits(12), + "full_name": fake.name(), } + parsed = self.client._parse_entrepreneur_short(data) + self.assertEqual(parsed.inn, data["inn"]) + self.assertIsNone(self.client._parse_leader([])) - cases = list( - self.client.iter_legal_cases(LegalCasesRequest(inn="0000000000")) + def test_parse_leader_empty_list_branch(self): + class _ToggleBoolList(list): + def __init__(self): + super().__init__() + self._first = True + + def __bool__(self): + if self._first: + self._first = False + return True + return False + + self.assertIsNone(self.client._parse_leader(_ToggleBoolList())) + + def test_get_person(self): + inn = _digits(12) + with TestHTTPServer() as server: + server.add_json( + "/v2/person", + { + "data": {"inn": inn, "full_name": fake.name()}, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + response = client.get_person(PersonRequest(inn=inn)) + + self.assertEqual(response.data.inn, inn) + + def test_contracts_inspections_enforcements_require_identifier(self): + with self.assertRaises(CheckoValidationError): + self.client.get_contracts(ContractsRequest(law=ContractLaw.FZ44)) + + with self.assertRaises(CheckoValidationError): + self.client.get_inspections(InspectionsRequest()) + + with self.assertRaises(CheckoValidationError): + self.client.get_enforcements(EnforcementsRequest()) + + +class CheckoRequestModelsTest(SimpleTestCase): + def test_company_request_to_params(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + req = CompanyRequest(inn=inn, source=True) + params = req.to_params() + self.assertEqual(params["inn"], inn) + self.assertEqual(params["source"], "true") + + def test_search_request_to_params(self): + query = fake.word() + fake.word() + req = SearchRequest( + by=SearchType.NAME, obj=ObjectType.ORGANIZATION, query=query ) + params = req.to_params() + self.assertEqual(params["by"], "name") + self.assertEqual(params["obj"], "org") + self.assertEqual(params["query"], query) - self.assertEqual(len(cases), 0) + def test_contracts_request_to_params(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + req = ContractsRequest(inn=inn, law=ContractLaw.FZ44) + params = req.to_params() + self.assertEqual(params["inn"], inn) + self.assertEqual(params["law"], "44") + + def test_legal_cases_request_to_params(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + req = LegalCasesRequest(inn=inn, limit=50) + params = req.to_params() + self.assertEqual(params["inn"], inn) + self.assertEqual(params["limit"], "50") + + +class CheckoDatasetsTest(SimpleTestCase): + def test_okved2_get(self): + items = OKVED2.all() + self.assertTrue(items) + self.assertIsNotNone(OKVED2.get(items[0].code)) + + def test_okved2_get_name(self): + items = OKVED2.all() + name = OKVED2.get_name(items[0].code) if items else None + self.assertIsInstance(name, str) + + def test_okved2_search(self): + items = OKVED2.all() + term = items[0].name.split(" ")[0] if items else fake.word() + results = OKVED2.search(term) + self.assertIsInstance(results, list) + + def test_okved2_exists(self): + items = OKVED2.all() + self.assertTrue(OKVED2.exists(items[0].code)) + + def test_okved2_get_children(self): + items = OKVED2.all() + prefix = items[0].code.split(".")[0] if items else "" + children = OKVED2.get_children(prefix) + self.assertIsInstance(children, list) + + def test_okfs_get(self): + items = OKFS.all() + self.assertTrue(items) + self.assertIsNotNone(OKFS.get(items[0].code)) + + def test_okfs_get_name(self): + items = OKFS.all() + name = OKFS.get_name(items[0].code) if items else None + self.assertIsInstance(name, str) + + def test_okopf_get(self): + items = OKOPF.all() + self.assertTrue(items) + self.assertIsNotNone(OKOPF.get(items[0].code)) + + def test_account_codes_get(self): + items = AccountCodes.all() + self.assertTrue(items) + self.assertIsNotNone(AccountCodes.get(items[0].code)) + + def test_company_statuses_get(self): + items = CompanyStatuses.all() + self.assertTrue(items) + self.assertIsNotNone(CompanyStatuses.get(items[0].code)) + + def test_entrepreneur_statuses_get(self): + items = EntrepreneurStatuses.all() + self.assertTrue(items) + self.assertIsNotNone(EntrepreneurStatuses.get(items[0].code)) + + def test_okpd_get(self): + items = OKPD.all() + self.assertTrue(items) + self.assertIsNotNone(OKPD.get(items[0].code)) + + def test_okpd2_get(self): + items = OKPD2.all() + self.assertTrue(items) + self.assertIsNotNone(OKPD2.get(items[0].code)) + + def test_okved2_parent_and_hierarchy(self): + items = OKVED2.all() + target = next((i for i in items if i.parent_code), items[0]) + parent = OKVED2.get_parent(target.code) + hierarchy = OKVED2.get_hierarchy(target.code) + self.assertTrue(hierarchy) + if target.parent_code: + self.assertIsNotNone(parent) + + def test_okpd2_parent_and_hierarchy(self): + items = OKPD2.all() + target = next((i for i in items if i.parent_code), items[0]) + parent = OKPD2.get_parent(target.code) + hierarchy = OKPD2.get_hierarchy(target.code) + self.assertTrue(hierarchy) + if target.parent_code: + self.assertIsNotNone(parent) + + def test_account_codes_helpers(self): + self.assertTrue(AccountCodes.get_balance_codes()) + self.assertTrue(AccountCodes.get_profit_loss_codes()) + self.assertTrue(AccountCodes.get_capital_codes()) + self.assertTrue(AccountCodes.get_cash_flow_codes()) + + def test_statuses_fallback(self): + from apps.parsers.clients.checko.datasets import statuses + + original_filename = statuses.CompanyStatuses._json_filename + original_data = statuses.CompanyStatuses._data + try: + statuses.CompanyStatuses._json_filename = "missing.json" + statuses.CompanyStatuses._data = None + items = statuses.CompanyStatuses.all() + self.assertTrue(items) + finally: + statuses.CompanyStatuses._json_filename = original_filename + statuses.CompanyStatuses._data = original_data + + +class CheckoClientIteratorsTest(SimpleTestCase): + def test_iter_contracts_pagination(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + contract_number = "".join(str(fake.random_int(0, 9)) for _ in range(12)) + + def contracts_handler(req, _body): + params = parse_qs(req.query) + page = int(params.get("page", ["1"])[0]) + payload = { + "data": { + "contracts": [ + { + "number": contract_number, + "sum": "1000.00", + "publish_date": str(fake.date()), + } + ], + "pagination": { + "total_records": 2, + "total_pages": 2, + "current_page": page, + }, + }, + "meta": _meta_ok(), + } + if page == 2: + payload["data"]["contracts"] = [] + return Response( + status=200, + body=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + with TestHTTPServer() as server: + server.add_route("GET", "/v2/contracts", contracts_handler) + client = _client_for(server) + contracts = list( + client.iter_contracts(ContractsRequest(inn=inn, law=ContractLaw.FZ44)) + ) + + self.assertTrue(contracts) + + +class CheckoExceptionsTest(SimpleTestCase): + def test_api_error_str_includes_details(self): + exc = CheckoAPIError("boom", status_code=500, balance=10.5) + self.assertIn("boom", str(exc)) + self.assertIn("status_code=500", str(exc)) + self.assertIn("balance=10.5", str(exc)) + + def test_connection_error_str(self): + exc = CheckoConnectionError("conn failed", url="http://example.com") + self.assertIn("url=http://example.com", str(exc)) + + def test_iter_legal_cases_empty(self): + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + + with TestHTTPServer() as server: + server.add_json( + "/v2/legal-cases", + { + "data": { + "cases": [], + "pagination": { + "total_records": 0, + "total_pages": 0, + "current_page": 1, + }, + }, + "meta": _meta_ok(), + }, + ) + client = _client_for(server) + cases = list(client.iter_legal_cases(LegalCasesRequest(inn=inn))) + + self.assertEqual(cases, []) diff --git a/tests/apps/parsers/test_checko_parsers.py b/tests/apps/parsers/test_checko_parsers.py new file mode 100644 index 0000000..9b6bcb7 --- /dev/null +++ b/tests/apps/parsers/test_checko_parsers.py @@ -0,0 +1,343 @@ +"""Extra parsing tests for Checko client (no mocks).""" + +from __future__ import annotations + +from django.test import SimpleTestCase + +from apps.parsers.clients.checko.client import CheckoClient, _map_ru_keys +from tests.utils.fixtures import fake + + +def _digits(count: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(count)) + + +class CheckoClientParsingTest(SimpleTestCase): + def setUp(self): + self.client = CheckoClient(api_key="test_key") + + def test_map_ru_keys_handles_nested(self): + data = { + "\u0418\u041d\u041d": _digits(10), + "\u042e\u0440\u0410\u0434\u0440\u0435\u0441": { + "\u0410\u0434\u0440\u0435\u0441\u0420\u0424": fake.address(), + }, + "\u0417\u0430\u043f\u0438\u0441\u0438": [ + {"\u041e\u0413\u0420\u041d": _digits(13)}, + "raw", + ], + } + mapped = _map_ru_keys(data) + + self.assertEqual(mapped["inn"], data["\u0418\u041d\u041d"]) + self.assertIn("legal_address", mapped) + self.assertIn("records", mapped) + self.assertEqual(mapped["records"][0]["ogrn"], data["\u0417\u0430\u043f\u0438\u0441\u0438"][0]["\u041e\u0413\u0420\u041d"]) + + def test_parse_okved_info_with_additional(self): + info = self.client._parse_okved_info( + {"code": "62.01", "name": "Development", "version": "2001"}, + [{"code": "62.02", "name": "Consulting"}, None], + ) + self.assertEqual(info.main.code, "62.01") + self.assertEqual(len(info.additional), 1) + + def test_parse_leader_and_founders_variants(self): + leader = self.client._parse_leader( + [ + { + "full_name": fake.name(), + "inn": _digits(12), + "position_name": fake.job(), + } + ] + ) + self.assertIsNotNone(leader) + + founders_list = self.client._parse_founders( + [ + {"full_name": fake.name(), "inn": _digits(12)}, + {"name": fake.company(), "ogrn": _digits(13)}, + ] + ) + self.assertEqual(len(founders_list), 2) + + founders_dict = self.client._parse_founders( + { + "\u0444\u043b": [{"full_name": fake.name(), "inn": _digits(12)}], + "\u0440\u043e\u0441\u043e\u0440\u0433": [{"name": fake.company()}], + } + ) + self.assertEqual(len(founders_dict), 2) + + def test_parse_branches_variants(self): + branches_list = self.client._parse_branches( + [ + {"full_name": fake.company(), "address": fake.address()}, + {"name": fake.company(), "country": "RU"}, + ] + ) + self.assertEqual(len(branches_list), 2) + + branches_dict = self.client._parse_branches( + { + "branch": [{"full_name": fake.company(), "address": fake.address()}], + "representative_office": [{"name": fake.company()}], + } + ) + self.assertEqual(len(branches_dict), 2) + + def test_parse_company_data_full(self): + data = { + "ogrn": _digits(13), + "inn": _digits(10), + "kpp": _digits(9), + "okpo": _digits(8), + "reg_date": str(fake.date()), + "short_name": fake.company(), + "full_name": fake.company(), + "status": {"code": "100", "name": "Active", "record_date": str(fake.date())}, + "legal_address": { + "full_address": fake.address(), + "region": {"code": "77", "name": "Moscow"}, + "city": fake.city(), + "street": fake.street_name(), + "building": fake.building_number(), + "apartment": fake.street_address(), + "postal_code": fake.postcode(), + "is_unreliable": True, + "is_mass_address": False, + }, + "leader": [ + { + "full_name": fake.name(), + "inn": _digits(12), + "position_name": fake.job(), + } + ], + "founders": {"\u0444\u043b": [{"full_name": fake.name()}]}, + "capital": {"value": 1000, "type_name": "RUB", "date": str(fake.date())}, + "okved": {"code": "62.01", "name": "Development", "version": "2001"}, + "okved_additional": [{"code": "62.02", "name": "Consulting"}], + "opf": {"code": "123", "name": "LLC"}, + "okfs": {"code": "16", "name": "Private"}, + "okogu": {"code": "4210014", "name": "Federal"}, + "region": {"code": "77", "name": "Moscow"}, + "tax_authority": { + "code": "7700", + "name": "IFNS", + "address": fake.address(), + "date": str(fake.date()), + }, + "tax_authority_local": { + "code": "7701", + "name": "IFNS Local", + "address": fake.address(), + "date": str(fake.date()), + }, + "pfr": { + "reg_date": str(fake.date()), + "reg_number": _digits(10), + "code": "087", + "name": "PFR", + }, + "fss": { + "reg_date": str(fake.date()), + "reg_number": _digits(10), + "code": "773", + "name": "FSS", + }, + "registrar": { + "ogrn": _digits(13), + "inn": _digits(10), + "full_name": fake.company(), + }, + "predecessors": [ + { + "ogrn": _digits(13), + "inn": _digits(10), + "kpp": _digits(9), + "full_name": fake.company(), + } + ], + "successors": [ + { + "ogrn": _digits(13), + "inn": _digits(10), + "kpp": _digits(9), + "full_name": fake.company(), + } + ], + "branches": {"branch": [{"full_name": fake.company(), "address": fake.address()}]}, + "licenses": [{"number": "L-1", "activities": [fake.word()]}], + "trademarks": [{"id": 1, "url": fake.url()}], + "tax_debt": {"total": 123, "date": str(fake.date())}, + "tax_penalty": {"penalties": 10, "fines": 5, "date": str(fake.date())}, + "msp": {"category": "micro", "include_date": str(fake.date()), "type": "A"}, + "msp_support": [ + { + "date": str(fake.date()), + "type": "grant", + "form": "cash", + "org_name": fake.company(), + "org_inn": _digits(10), + "amount": 42, + "violation": True, + } + ], + "bankruptcy": [{"type": "msg", "date": str(fake.date())}], + "unfair_supplier": [{"registry_number": "123", "purchase_number": "1"}], + "related_companies": [{"ogrn": _digits(13), "inn": _digits(10)}], + "statistics": { + "contracts_44_customer_count": 1, + "contracts_44_supplier_count": 2, + "contracts_223_customer_count": 3, + "contracts_223_supplier_count": 4, + "legal_cases_plaintiff_count": 5, + "legal_cases_defendant_count": 6, + "inspections_count": 7, + "enforcements_count": 8, + }, + "employees_count": 10, + "employees_count_date": str(fake.date()), + "liquidation_date": str(fake.date()), + } + + parsed = self.client._parse_company_data(data) + self.assertEqual(parsed.ogrn, data["ogrn"]) + self.assertEqual(parsed.status.code, "100") + self.assertEqual(len(parsed.founders), 1) + self.assertEqual(len(parsed.branches), 1) + self.assertEqual(parsed.tax_authority.code, "7700") + + def test_parse_other_data_blocks(self): + entrepreneur = self.client._parse_entrepreneur_data( + { + "ogrnip": _digits(15), + "inn": _digits(12), + "full_name": fake.name(), + "status": {"code": "100", "name": "Active"}, + "region": {"code": "78", "name": "SPB"}, + "tax_authority": {"code": "7800", "name": "IFNS"}, + "okved": {"code": "47.19", "name": "Retail"}, + "licenses": [{"number": "L-2"}], + } + ) + self.assertIsNotNone(entrepreneur) + self.assertEqual(entrepreneur.status.code, "100") + + person = self.client._parse_person_data( + { + "inn": _digits(12), + "full_name": fake.name(), + "disqualifications": [ + {"date_from": str(fake.date()), "reason": fake.sentence(nb_words=4)} + ], + "companies_as_leader": [ + {"ogrn": _digits(13), "short_name": fake.company()} + ], + "companies_as_founder": [ + {"ogrn": _digits(13), "short_name": fake.company()} + ], + "entrepreneurs": [ + {"ogrnip": _digits(15), "full_name": fake.name()} + ], + } + ) + self.assertEqual(len(person.disqualifications), 1) + self.assertEqual(len(person.companies_as_leader), 1) + + finances = self.client._parse_finances_data( + { + "ogrn": _digits(13), + "inn": _digits(10), + "reports": [ + { + "year": 2023, + "balance": [{"code": "1100", "current": 10}], + "profit_loss": [{"code": "2100", "current": 20}], + } + ], + "summary": {"revenue": 100, "profit": 10}, + } + ) + self.assertEqual(len(finances.reports), 1) + self.assertIsNotNone(finances.summary) + + contracts = self.client._parse_contracts_data( + { + "contracts": [ + { + "registry_number": "c-1", + "customer": {"inn": _digits(10)}, + "suppliers": [{"inn": _digits(10)}, None], + } + ], + "pagination": {"total_records": 1, "total_pages": 1, "current_page": 1}, + "total_sum": 123, + } + ) + self.assertEqual(len(contracts.contracts), 1) + self.assertEqual(len(contracts.contracts[0].suppliers), 1) + + inspections = self.client._parse_inspections_data( + { + "inspections": [ + { + "id": 1, + "type": "planned", + "status": "done", + "violations_found": True, + } + ], + "pagination": {"total_records": 1, "total_pages": 1, "current_page": 1}, + } + ) + self.assertEqual(len(inspections.inspections), 1) + + enforcements = self.client._parse_enforcements_data( + { + "enforcements": [{"number": "e-1", "status": "open"}], + "pagination": {"total_records": 1, "total_pages": 1, "current_page": 1}, + "total_debt": 555, + } + ) + self.assertEqual(len(enforcements.enforcements), 1) + + legal_cases = self.client._parse_legal_cases_data( + { + "cases": [ + { + "case_number": "A40-1/2024", + "plaintiffs": [{"name": fake.company()}], + "defendants": [{"name": fake.company()}], + "instances": [{"number": "1", "court_name": "Court"}], + } + ], + "pagination": {"total_records": 1, "total_pages": 1, "current_page": 1}, + "total_claim_amount": 1000, + } + ) + self.assertEqual(len(legal_cases.cases), 1) + + bank = self.client._parse_bank_data( + { + "bic": "044525225", + "name": "Bank", + "short_name": "BK", + "corr_account": _digits(20), + "status": "active", + } + ) + self.assertEqual(bank.bic, "044525225") + + def test_parse_search_data_with_pagination(self): + search = self.client._parse_search_data( + { + "records": [ + {"ogrn": _digits(13), "inn": _digits(10), "short_name": "A"} + ], + "pagination": {"total_records": 1, "total_pages": 1, "current_page": 1}, + } + ) + self.assertEqual(len(search.organizations), 1) diff --git a/tests/apps/parsers/test_clients.py b/tests/apps/parsers/test_clients.py index 30bc53e..42792d5 100644 --- a/tests/apps/parsers/test_clients.py +++ b/tests/apps/parsers/test_clients.py @@ -1,248 +1,324 @@ -"""Tests for parsers clients.""" +"""Integration-style tests for parsers clients using a local HTTP server.""" -from io import BytesIO -from unittest.mock import patch +from __future__ import annotations -from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError +from urllib.parse import urlparse + +import requests +from requests.adapters import BaseAdapter + +from apps.parsers.clients.base import ( + BaseHTTPClient, + ConnectionError, + HTTPClientError, + HTTPError, +) from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient from apps.parsers.clients.minpromtorg.manufactures import ManufacturesClient from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer from apps.parsers.clients.proverki import ProverkiClient from apps.parsers.clients.proverki.schemas import Inspection from django.test import TestCase, tag -from faker import Faker -from openpyxl import Workbook -fake = Faker("ru_RU") +from tests.utils import Response, TestHTTPServer +from tests.utils.fixtures import ( + build_minpromtorg_certificates_excel, + build_minpromtorg_manufacturers_excel, + build_proverki_xml, + fake, +) + + +def _host_from_base_url(base_url: str) -> str: + parsed = urlparse(base_url) + if parsed.port: + return f"{parsed.hostname}:{parsed.port}" + return parsed.hostname or "" + + +def _base_url() -> str: + return f"https://{fake.domain_name()}" + + +def _proxy_address() -> str: + return f"http://{fake.ipv4()}:{fake.port_number()}" + + +class _RaisingAdapter(BaseAdapter): + def __init__(self, exc: Exception) -> None: + super().__init__() + self._exc = exc + + def send(self, _request, **_kwargs): + raise self._exc + + def close(self) -> None: + return class BaseHTTPClientTest(TestCase): """Tests for BaseHTTPClient.""" def test_client_initialization(self): - """Test client initializes with defaults.""" - client = BaseHTTPClient(base_url="https://example.com") - - self.assertEqual(client.base_url, "https://example.com") + base_url = _base_url() + client = BaseHTTPClient(base_url=base_url) + self.assertEqual(client.base_url, base_url) self.assertIsNone(client.proxies) self.assertEqual(client.timeout, 30) def test_client_with_proxies(self): - """Test client initializes with proxy list.""" - proxies = ["http://proxy1:8080", "http://proxy2:8080"] - client = BaseHTTPClient(base_url="https://example.com", proxies=proxies) - + proxies = [_proxy_address(), _proxy_address()] + client = BaseHTTPClient(base_url=_base_url(), proxies=proxies) self.assertEqual(client.proxies, proxies) def test_select_proxy_returns_none_without_proxies(self): - """Test _select_proxy returns None when no proxies.""" - client = BaseHTTPClient(base_url="https://example.com") + client = BaseHTTPClient(base_url=_base_url()) self.assertIsNone(client._select_proxy()) def test_select_proxy_returns_random_from_list(self): - """Test _select_proxy returns proxy from list.""" - proxies = ["http://proxy1:8080", "http://proxy2:8080"] - client = BaseHTTPClient(base_url="https://example.com", proxies=proxies) - + proxies = [_proxy_address(), _proxy_address()] + client = BaseHTTPClient(base_url=_base_url(), proxies=proxies) selected = client._select_proxy() self.assertIn(selected, proxies) def test_current_proxy_property(self): - """Test current_proxy property is None before session creation.""" - proxies = ["http://proxy:8080"] - client = BaseHTTPClient(base_url="https://example.com", proxies=proxies) - - # current_proxy is None until session is created - proxy = client.current_proxy - self.assertIsNone(proxy) - - # After accessing session, proxy should be set + proxies = [_proxy_address()] + client = BaseHTTPClient(base_url=_base_url(), proxies=proxies) + self.assertIsNone(client.current_proxy) _ = client.session - proxy = client.current_proxy - self.assertEqual(proxy, "http://proxy:8080") + self.assertEqual(client.current_proxy, proxies[0]) + def test_build_url_with_full_url(self): + full = "https://example.com/path" + client = BaseHTTPClient(base_url=_base_url()) + self.assertEqual(client._build_url(full), full) -def _create_test_excel_certificates() -> bytes: - """Create test Excel file with certificate data.""" - wb = Workbook() - ws = wb.active + def test_get_json_and_download_file(self): + with TestHTTPServer() as server: + server.add_json("/api/data", {"ok": True}) + server.add_bytes("/files/data.bin", b"payload") + client = BaseHTTPClient(base_url=server.base_url, adapter=server.adapter) + data = client.get_json("/api/data") + content = client.download_file("/files/data.bin") - # Header - ws.append( - [ - "issue_date", - "certificate_number", - "expiry_date", - "certificate_file_url", - "organisation_name", - "inn", - "ogrn", - ] - ) + self.assertTrue(data["ok"]) + self.assertEqual(content, b"payload") - # Data rows - for i in range(5): - ws.append( - [ - "2024-01-01", - f"CERT-{i:04d}", - "2025-01-01", - f"https://example.com/cert{i}.pdf", - f"Company {i} LLC", - f"123456789{i}", - f"123456789012{i}", - ] - ) + def test_post_success_and_error(self): + def echo_handler(_req, body): + return Response(status=200, body=body, headers={}) - output = BytesIO() - wb.save(output) - output.seek(0) - return output.read() + with TestHTTPServer() as server: + server.add_route("POST", "/echo", echo_handler) + server.add_route("GET", "/missing", lambda _req, _body: Response(status=404)) + client = BaseHTTPClient(base_url=server.base_url, adapter=server.adapter) + result = client.post("/echo", data=b"ping") + self.assertEqual(result, b"ping") + with self.assertRaises(HTTPError): + client.get("/missing") + server.add_route("POST", "/error", lambda _req, _body: Response(status=500)) + with self.assertRaises(HTTPError): + client.post("/error", data=b"fail") -def _create_test_excel_manufacturers() -> bytes: - """Create test Excel file with manufacturer data.""" - wb = Workbook() - ws = wb.active + def test_download_file_error(self): + with TestHTTPServer() as server: + server.add_route("GET", "/missing.bin", lambda _req, _body: Response(status=404)) + client = BaseHTTPClient(base_url=server.base_url, adapter=server.adapter) + with self.assertRaises(HTTPError): + client.download_file("/missing.bin") - # Header - ws.append(["full_legal_name", "inn", "ogrn", "address"]) + def test_connection_error(self): + client = BaseHTTPClient(base_url="http://127.0.0.1:1", timeout=0.01) + with self.assertRaises(ConnectionError): + client.get("/unreachable") - # Data rows - for i in range(5): - ws.append( - [ - f"Manufacturer {i} LLC", - f"123456789{i}", - f"123456789012{i}", - f"Address {i}, City", - ] - ) + def test_context_manager_closes_session(self): + with TestHTTPServer() as server: + server.add_json("/ping", {"ok": True}) + with BaseHTTPClient(base_url=server.base_url, adapter=server.adapter) as client: + client.get_json("/ping") + self.assertIsNotNone(client._session) + self.assertIsNone(client._session) - output = BytesIO() - wb.save(output) - output.seek(0) - return output.read() + def test_rotate_proxy(self): + proxies = [_proxy_address(), _proxy_address()] + client = BaseHTTPClient(base_url=_base_url(), proxies=proxies) + first = client.rotate_proxy() + self.assertIn(first, proxies) + + def test_https_base_url_mounts_adapter(self): + with TestHTTPServer() as server: + base_url = f"https://{fake.domain_name()}" + client = BaseHTTPClient(base_url=base_url, adapter=server.adapter) + session = client.session + self.assertIsNotNone(session) + + def test_rotate_proxy_closes_existing_session(self): + proxies = [_proxy_address()] + client = BaseHTTPClient(base_url=_base_url(), proxies=proxies) + _ = client.session + self.assertIsNotNone(client._session) + client.rotate_proxy() + self.assertIsNone(client._session) + + def test_get_timeout_raises_connection_error(self): + adapter = _RaisingAdapter(requests.exceptions.Timeout(fake.sentence())) + client = BaseHTTPClient(base_url=_base_url(), adapter=adapter) + with self.assertRaises(ConnectionError): + client.get("/timeout") + + def test_get_request_exception_raises_http_client_error(self): + adapter = _RaisingAdapter(requests.exceptions.RequestException(fake.sentence())) + client = BaseHTTPClient(base_url=_base_url(), adapter=adapter) + with self.assertRaises(HTTPClientError): + client.get("/boom") + + def test_post_connection_error_raises_connection_error(self): + adapter = _RaisingAdapter(requests.exceptions.ConnectionError(fake.sentence())) + client = BaseHTTPClient(base_url=_base_url(), adapter=adapter) + with self.assertRaises(ConnectionError): + client.post("/fail", data=fake.pystr(min_chars=5, max_chars=10)) + + def test_post_timeout_raises_connection_error(self): + adapter = _RaisingAdapter(requests.exceptions.Timeout(fake.sentence())) + client = BaseHTTPClient(base_url=_base_url(), adapter=adapter) + with self.assertRaises(ConnectionError): + client.post("/timeout", data=fake.pystr(min_chars=5, max_chars=10)) + + def test_post_request_exception_raises_http_client_error(self): + adapter = _RaisingAdapter(requests.exceptions.RequestException(fake.sentence())) + client = BaseHTTPClient(base_url=_base_url(), adapter=adapter) + with self.assertRaises(HTTPClientError): + client.post("/boom", data=fake.pystr(min_chars=5, max_chars=10)) + + def test_download_timeout_raises_connection_error(self): + adapter = _RaisingAdapter(requests.exceptions.Timeout(fake.sentence())) + client = BaseHTTPClient(base_url=_base_url(), adapter=adapter) + with self.assertRaises(ConnectionError): + client.download_file("/timeout.bin") + + def test_download_request_exception_raises_http_client_error(self): + adapter = _RaisingAdapter(requests.exceptions.RequestException(fake.sentence())) + client = BaseHTTPClient(base_url=_base_url(), adapter=adapter) + with self.assertRaises(HTTPClientError): + client.download_file("/boom.bin") class IndustrialProductionClientTest(TestCase): """Tests for IndustrialProductionClient.""" def test_client_initialization(self): - """Test client initializes correctly.""" client = IndustrialProductionClient() - self.assertIsNone(client.proxies) self.assertEqual(client.host, "minpromtorg.gov.ru") def test_client_with_proxies(self): - """Test client accepts proxy list.""" - proxies = ["http://proxy1:8080", "http://proxy2:8080"] + proxies = [_proxy_address(), _proxy_address()] client = IndustrialProductionClient(proxies=proxies) - self.assertEqual(client.proxies, proxies) def test_context_manager(self): - """Test client works as context manager.""" with IndustrialProductionClient() as client: self.assertIsInstance(client, IndustrialProductionClient) - @patch.object(BaseHTTPClient, "get_json") - @patch.object(BaseHTTPClient, "download_file") - def test_fetch_certificates_success(self, mock_download, mock_get_json): - """Test successful certificate fetching.""" - # Mock API response - mock_get_json.return_value = { - "data": [ + def test_fetch_certificates_success(self): + excel_bytes, rows = build_minpromtorg_certificates_excel(count=5) + date_str = fake.date_between(start_date="-30d", end_date="today").strftime( + "%Y%m%d" + ) + file_name = f"data_resolutions_{date_str}.xlsx" + + with TestHTTPServer() as server: + server.add_json( + "/api/kss-document-preview", { - "name": "Заключения о подтверждении производства промышленной продукции на территории Российской Федерации", - "files": [ + "data": [ { - "name": "data_resolutions_20240101.xlsx", - "url": "/files/test.xlsx", - }, - ], - } - ] - } + "name": IndustrialProductionClient().query, + "files": [ + {"name": file_name, "url": f"/files/{file_name}"} + ], + } + ] + }, + ) + server.add_bytes( + f"/files/{file_name}", + excel_bytes, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) - # Mock Excel download - mock_download.return_value = _create_test_excel_certificates() - - with IndustrialProductionClient() as client: + client = IndustrialProductionClient( + host=_host_from_base_url(server.base_url), + scheme="http", + http_adapter=server.adapter, + ) certificates = client.fetch_certificates() - self.assertEqual(len(certificates), 5) + self.assertEqual(len(certificates), len(rows)) self.assertIsInstance(certificates[0], IndustrialCertificate) - self.assertEqual(certificates[0].certificate_number, "CERT-0000") + self.assertSetEqual( + {c.certificate_number for c in certificates}, + {r.certificate_number for r in rows}, + ) - @patch.object(BaseHTTPClient, "get_json") - def test_fetch_certificates_no_files(self, mock_get_json): - """Test returns empty list when no files found.""" - mock_get_json.return_value = {"data": []} - - with IndustrialProductionClient() as client: + def test_fetch_certificates_no_files(self): + with TestHTTPServer() as server: + server.add_json("/api/kss-document-preview", {"data": []}) + client = IndustrialProductionClient( + host=_host_from_base_url(server.base_url), + scheme="http", + http_adapter=server.adapter, + ) certificates = client.fetch_certificates() self.assertEqual(certificates, []) - @patch.object(BaseHTTPClient, "get_json") - def test_get_latest_file_url_selects_newest(self, mock_get_json): - """Test selects file with latest date.""" - mock_get_json.return_value = { - "data": [ - { - "name": "Заключения о подтверждении производства промышленной продукции на территории Российской Федерации", - "files": [ - { - "name": "data_resolutions_20240101.xlsx", - "url": "/files/old.xlsx", - }, - { - "name": "data_resolutions_20240315.xlsx", - "url": "/files/new.xlsx", - }, - { - "name": "data_resolutions_20240201.xlsx", - "url": "/files/mid.xlsx", - }, - ], - } - ] - } - + def test_get_latest_file_url_selects_newest(self): client = IndustrialProductionClient() - files_data = client._fetch_files_list() - url = client._get_latest_file_url(files_data) + dates = sorted( + { + fake.date_between(start_date="-90d", end_date="today") + for _ in range(3) + } + ) + files = [] + for date in dates: + date_str = date.strftime("%Y%m%d") + files.append( + { + "name": f"data_resolutions_{date_str}.xlsx", + "url": f"/files/{date_str}.xlsx", + } + ) - self.assertIn("new.xlsx", url) + url = client._get_latest_file_url(files) + self.assertIn(dates[-1].strftime("%Y%m%d"), url) def test_parse_row_valid(self): - """Test parsing valid row.""" client = IndustrialProductionClient() row = ( - "2024-01-01", - "CERT-123", - "2025-01-01", - "https://example.com/cert.pdf", - "Test Company", - "1234567890", - "1234567890123", + str(fake.date()), + fake.bothify(text="??-####-#####"), + str(fake.date()), + fake.url(), + fake.company(), + "".join(str(fake.random_int(0, 9)) for _ in range(10)), + "".join(str(fake.random_int(0, 9)) for _ in range(13)), ) result = client._parse_row(row) self.assertIsInstance(result, IndustrialCertificate) - self.assertEqual(result.certificate_number, "CERT-123") - self.assertEqual(result.inn, "1234567890") + self.assertEqual(result.certificate_number, row[1]) + self.assertEqual(result.inn, row[5]) def test_parse_row_invalid(self): - """Test parsing invalid row returns None.""" client = IndustrialProductionClient() - row = ("only", "two") # Not enough columns - - result = client._parse_row(row) - + result = client._parse_row(("only", "two")) self.assertIsNone(result) @@ -250,97 +326,114 @@ class ManufacturesClientTest(TestCase): """Tests for ManufacturesClient.""" def test_client_initialization(self): - """Test client initializes correctly.""" client = ManufacturesClient() - self.assertIsNone(client.proxies) self.assertEqual(client.host, "minpromtorg.gov.ru") def test_client_with_proxies(self): - """Test client accepts proxy list.""" - proxies = ["http://proxy1:8080", "http://proxy2:8080"] + proxies = [_proxy_address(), _proxy_address()] client = ManufacturesClient(proxies=proxies) - self.assertEqual(client.proxies, proxies) def test_context_manager(self): - """Test client works as context manager.""" with ManufacturesClient() as client: self.assertIsInstance(client, ManufacturesClient) - @patch.object(BaseHTTPClient, "get_json") - @patch.object(BaseHTTPClient, "download_file") - def test_fetch_manufacturers_success(self, mock_download, mock_get_json): - """Test successful manufacturer fetching.""" - # Mock API response - mock_get_json.return_value = { - "data": [ + def test_fetch_manufacturers_success(self): + excel_bytes, rows = build_minpromtorg_manufacturers_excel(count=5) + date_str = fake.date_between(start_date="-30d", end_date="today").strftime( + "%Y%m%d" + ) + file_name = f"data_orgs_{date_str}.xlsx" + + with TestHTTPServer() as server: + server.add_json( + "/api/kss-document-preview", { - "name": "Производители промышленной продукции", - "files": [ - {"name": "data_orgs_20240101.xlsx", "url": "/files/test.xlsx"}, - ], - } - ] - } + "data": [ + { + "name": ManufacturesClient().query, + "files": [ + {"name": file_name, "url": f"/files/{file_name}"} + ], + } + ] + }, + ) + server.add_bytes( + f"/files/{file_name}", + excel_bytes, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) - # Mock Excel download - mock_download.return_value = _create_test_excel_manufacturers() - - with ManufacturesClient() as client: + client = ManufacturesClient( + host=_host_from_base_url(server.base_url), + scheme="http", + http_adapter=server.adapter, + ) manufacturers = client.fetch_manufacturers() - self.assertEqual(len(manufacturers), 5) + self.assertEqual(len(manufacturers), len(rows)) self.assertIsInstance(manufacturers[0], Manufacturer) - self.assertEqual(manufacturers[0].full_legal_name, "Manufacturer 0 LLC") + self.assertSetEqual( + {m.full_legal_name for m in manufacturers}, + {r.full_legal_name for r in rows}, + ) - @patch.object(BaseHTTPClient, "get_json") - def test_fetch_manufacturers_no_files(self, mock_get_json): - """Test returns empty list when no files found.""" - mock_get_json.return_value = {"data": []} - - with ManufacturesClient() as client: + def test_fetch_manufacturers_no_files(self): + with TestHTTPServer() as server: + server.add_json("/api/kss-document-preview", {"data": []}) + client = ManufacturesClient( + host=_host_from_base_url(server.base_url), + scheme="http", + http_adapter=server.adapter, + ) manufacturers = client.fetch_manufacturers() self.assertEqual(manufacturers, []) - @patch.object(BaseHTTPClient, "get_json") - def test_get_latest_file_url_selects_newest(self, mock_get_json): - """Test selects file with latest date.""" - mock_get_json.return_value = { - "data": [ - { - "name": "Производители промышленной продукции", - "files": [ - {"name": "data_orgs_20240101.xlsx", "url": "/files/old.xlsx"}, - {"name": "data_orgs_20240315.xlsx", "url": "/files/new.xlsx"}, - {"name": "data_orgs_20240201.xlsx", "url": "/files/mid.xlsx"}, - ], - } - ] - } - + def test_get_latest_file_url_selects_newest(self): client = ManufacturesClient() - files_data = client._fetch_files_list() - url = client._get_latest_file_url(files_data) + dates = sorted( + { + fake.date_between(start_date="-90d", end_date="today") + for _ in range(3) + } + ) + files = [] + for date in dates: + date_str = date.strftime("%Y%m%d") + files.append( + {"name": f"data_orgs_{date_str}.xlsx", "url": f"/files/{date_str}"} + ) - self.assertIn("new.xlsx", url) + url = client._get_latest_file_url(files) + self.assertIn(dates[-1].strftime("%Y%m%d"), url) def test_parse_row_valid(self): - """Test parsing valid row.""" client = ManufacturesClient() - row = ("Test Company LLC", "1234567890", "1234567890123", "Test Address") + row = ( + fake.company(), + "".join(str(fake.random_int(0, 9)) for _ in range(10)), + "".join(str(fake.random_int(0, 9)) for _ in range(13)), + fake.address().replace("\n", ", "), + ) result = client._parse_row(row) self.assertIsInstance(result, Manufacturer) - self.assertEqual(result.full_legal_name, "Test Company LLC") - self.assertEqual(result.inn, "1234567890") + self.assertEqual(result.full_legal_name, row[0]) + self.assertEqual(result.inn, row[1]) def test_parse_row_without_address(self): - """Test parsing row without address.""" client = ManufacturesClient() - row = ("Test Company LLC", "1234567890", "1234567890123") + row = ( + fake.company(), + "".join(str(fake.random_int(0, 9)) for _ in range(10)), + "".join(str(fake.random_int(0, 9)) for _ in range(13)), + ) result = client._parse_row(row) @@ -348,307 +441,227 @@ class ManufacturesClientTest(TestCase): self.assertEqual(result.address, "") -@tag("integration", "slow", "network") +@tag("integration", "slow") class IndustrialProductionClientIntegrationTest(TestCase): - """ - Интеграционные тесты с реальной загрузкой данных. + """Integration test using local HTTP server instead of external API.""" - ВНИМАНИЕ: Эти тесты делают реальные HTTP запросы к внешним серверам. - Запускать с тегом: python manage.py test --tag=integration - """ + def test_fetch_certificates_local_server(self): + excel_bytes, rows = build_minpromtorg_certificates_excel(count=3) + date_str = fake.date_between(start_date="-30d", end_date="today").strftime( + "%Y%m%d" + ) + file_name = f"data_resolutions_{date_str}.xlsx" - def test_fetch_certificates_real_data(self): - """ - Интеграционный тест: реальная загрузка сертификатов с gisp.gov.ru. + with TestHTTPServer() as server: + server.add_json( + "/api/kss-document-preview", + { + "data": [ + { + "name": IndustrialProductionClient().query, + "files": [ + {"name": file_name, "url": f"/files/{file_name}"} + ], + } + ] + }, + ) + server.add_bytes(f"/files/{file_name}", excel_bytes) - Этот тест: - 1. Подключается к реальному API - 2. Скачивает Excel файл - 3. Парсит данные - 4. Проверяет структуру результата + client = IndustrialProductionClient( + host=_host_from_base_url(server.base_url), + scheme="http", + timeout=30, + http_adapter=server.adapter, + ) + certificates = client.fetch_certificates() - Тест может занять время и зависит от доступности внешнего сервера. - """ - try: - with IndustrialProductionClient(timeout=120) as client: - certificates = client.fetch_certificates() - - # Проверяем что данные получены - self.assertIsInstance(certificates, list) - - # Если данные есть - проверяем структуру - if certificates: - cert = certificates[0] - self.assertIsInstance(cert, IndustrialCertificate) - self.assertIsNotNone(cert.certificate_number) - self.assertIsNotNone(cert.inn) - self.assertIsNotNone(cert.organisation_name) - - # Логируем для информации - print(f"\n[INTEGRATION] Loaded {len(certificates)} certificates") - print(f"[INTEGRATION] First certificate: {cert.certificate_number}") - print(f"[INTEGRATION] Organisation: {cert.organisation_name}") - else: - print("\n[INTEGRATION] No certificates found (API may be unavailable)") - - except HTTPClientError as e: - # API может быть недоступен - это ожидаемое поведение для интеграционных тестов - self.skipTest(f"External API unavailable: {e}") + self.assertEqual(len(certificates), len(rows)) -@tag("integration", "slow", "network") +@tag("integration", "slow") class ManufacturesClientIntegrationTest(TestCase): - """ - Интеграционные тесты для клиента производителей. + """Integration test using local HTTP server instead of external API.""" - ВНИМАНИЕ: Эти тесты делают реальные HTTP запросы к внешним серверам. - Запускать с тегом: python manage.py test --tag=integration - """ + def test_fetch_manufacturers_local_server(self): + excel_bytes, rows = build_minpromtorg_manufacturers_excel(count=3) + date_str = fake.date_between(start_date="-30d", end_date="today").strftime( + "%Y%m%d" + ) + file_name = f"data_orgs_{date_str}.xlsx" - def test_fetch_manufacturers_real_data(self): - """ - Интеграционный тест: реальная загрузка производителей с gisp.gov.ru. - """ - try: - with ManufacturesClient(timeout=120) as client: - manufacturers = client.fetch_manufacturers() + with TestHTTPServer() as server: + server.add_json( + "/api/kss-document-preview", + { + "data": [ + { + "name": ManufacturesClient().query, + "files": [ + {"name": file_name, "url": f"/files/{file_name}"} + ], + } + ] + }, + ) + server.add_bytes(f"/files/{file_name}", excel_bytes) - # Проверяем что данные получены - self.assertIsInstance(manufacturers, list) + client = ManufacturesClient( + host=_host_from_base_url(server.base_url), + scheme="http", + timeout=30, + http_adapter=server.adapter, + ) + manufacturers = client.fetch_manufacturers() - # Если данные есть - проверяем структуру - if manufacturers: - m = manufacturers[0] - self.assertIsInstance(m, Manufacturer) - self.assertIsNotNone(m.full_legal_name) - self.assertIsNotNone(m.inn) - - # Логируем для информации - print(f"\n[INTEGRATION] Loaded {len(manufacturers)} manufacturers") - print(f"[INTEGRATION] First manufacturer: {m.full_legal_name}") - print(f"[INTEGRATION] INN: {m.inn}") - else: - print("\n[INTEGRATION] No manufacturers found (API may be unavailable)") - - except HTTPClientError as e: - # API может быть недоступен - это ожидаемое поведение для интеграционных тестов - self.skipTest(f"External API unavailable: {e}") - - -def _create_test_xml_inspections() -> bytes: - """Create test XML file with inspection data.""" - xml_content = """ - - - 772024000001 - 7701234567 - 1027700000001 - ООО "Тест Компания 1" - Роспотребнадзор - плановая - документарная - 2024-01-15 - 2024-01-30 - завершена - 294-ФЗ - нарушения не выявлены - - - 772024000002 - 7702345678 - 1027700000002 - АО "Тест Компания 2" - Ростехнадзор - внеплановая - выездная - 2024-02-01 - 2024-02-15 - завершена - 248-ФЗ - выявлены нарушения - -""" - return xml_content.encode("utf-8") - - -def _create_test_xml_inspections_russian_tags() -> bytes: - """Create test XML with Russian tag names.""" - xml_content = """ -<Проверки> - <КНМ> - <УчетныйНомер>772024000003 - <ИНН>7703456789 - <ОГРН>1027700000003 - <Наименование>ПАО "Тест Компания 3" - <КонтрольныйОрган>МЧС России - <ТипПроверки>плановая - <ФормаПроверки>документарная и выездная - <ДатаНачала>2024-03-01 - <ДатаОкончания>2024-03-20 - <Статус>в процессе - <ПравовоеОснование>294-ФЗ - -""" - return xml_content.encode("utf-8") + self.assertEqual(len(manufacturers), len(rows)) class ProverkiClientTest(TestCase): """Tests for ProverkiClient.""" def test_client_initialization(self): - """Test client initializes correctly.""" client = ProverkiClient() - self.assertIsNone(client.proxies) self.assertEqual(client.host, "proverki.gov.ru") def test_client_with_proxies(self): - """Test client accepts proxy list.""" - proxies = ["http://proxy1:8080", "http://proxy2:8080"] + proxies = [_proxy_address(), _proxy_address()] client = ProverkiClient(proxies=proxies) - self.assertEqual(client.proxies, proxies) def test_context_manager(self): - """Test client works as context manager.""" with ProverkiClient() as client: self.assertIsInstance(client, ProverkiClient) def test_parse_xml_content_english_tags(self): - """Test parsing XML with English tag names.""" client = ProverkiClient() - xml_content = _create_test_xml_inspections() + xml_content, rows = build_proverki_xml(count=2) inspections = client._parse_xml_content(xml_content, None) - self.assertEqual(len(inspections), 2) + self.assertEqual(len(inspections), len(rows)) self.assertIsInstance(inspections[0], Inspection) - self.assertEqual(inspections[0].registration_number, "772024000001") - self.assertEqual(inspections[0].inn, "7701234567") - self.assertEqual(inspections[0].organisation_name, 'ООО "Тест Компания 1"') - self.assertEqual(inspections[0].control_authority, "Роспотребнадзор") - self.assertEqual(inspections[0].inspection_type, "плановая") - self.assertEqual(inspections[0].legal_basis, "294-ФЗ") + self.assertSetEqual( + {i.registration_number for i in inspections}, + {r.registration_number for r in rows}, + ) def test_parse_xml_content_russian_tags(self): - """Test parsing XML with Russian tag names.""" client = ProverkiClient() - xml_content = _create_test_xml_inspections_russian_tags() + reg_num = "".join(str(fake.random_int(0, 9)) for _ in range(12)) + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + ogrn = "".join(str(fake.random_int(0, 9)) for _ in range(13)) + org_name = fake.company() + authority = fake.company() + xml_content = ( + "" + "<Проверки>" + "<КНМ>" + f"<УчетныйНомер>{reg_num}" + f"<ИНН>{inn}" + f"<ОГРН>{ogrn}" + f"<Наименование>{org_name}" + f"<КонтрольныйОрган>{authority}" + "" + "" + ).encode("utf-8") inspections = client._parse_xml_content(xml_content, None) self.assertEqual(len(inspections), 1) - self.assertIsInstance(inspections[0], Inspection) - self.assertEqual(inspections[0].registration_number, "772024000003") - self.assertEqual(inspections[0].inn, "7703456789") - self.assertEqual(inspections[0].control_authority, "МЧС России") + self.assertEqual(inspections[0].registration_number, reg_num) + self.assertEqual(inspections[0].inn, inn) + self.assertEqual(inspections[0].control_authority, authority) def test_parse_xml_record_with_attributes(self): - """Test parsing XML record with attributes instead of child elements.""" from xml.etree import ElementTree as ET - client = ProverkiClient() - xml_str = '' - element = ET.fromstring(xml_str) # noqa: S314 + row_inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + reg_num = "".join(str(fake.random_int(0, 9)) for _ in range(12)) + element = ET.fromstring( + f"" + ) # noqa: S314 + client = ProverkiClient() result = client._parse_xml_record(element) self.assertIsNotNone(result) - self.assertEqual(result.inn, "1234567890") - self.assertEqual(result.registration_number, "TEST123") + self.assertEqual(result.inn, row_inn) + self.assertEqual(result.registration_number, reg_num) def test_parse_xml_record_invalid(self): - """Test parsing invalid XML record returns None.""" from xml.etree import ElementTree as ET + element = ET.fromstring("") # noqa: S314 client = ProverkiClient() - xml_str = "" - element = ET.fromstring(xml_str) # noqa: S314 - - result = client._parse_xml_record(element) - - self.assertIsNone(result) + self.assertIsNone(client._parse_xml_record(element)) def test_parse_windows_1251_encoding(self): - """Test parsing XML with Windows-1251 encoding.""" - client = ProverkiClient() - xml_content = """ - - - 1234567890 - TEST001 - Компания - -""".encode("windows-1251") + org_name = fake.company() + inn = "".join(str(fake.random_int(0, 9)) for _ in range(10)) + reg_num = "".join(str(fake.random_int(0, 9)) for _ in range(12)) + xml_content = ( + "" + "" + "" + f"{inn}" + f"{reg_num}" + f"{org_name}" + "" + "" + ).encode("windows-1251") + client = ProverkiClient() inspections = client._parse_xml_content(xml_content, None) self.assertEqual(len(inspections), 1) - self.assertEqual(inspections[0].organisation_name, "Компания") + self.assertEqual(inspections[0].organisation_name, org_name) - @patch.object(BaseHTTPClient, "download_file") - @patch.object(ProverkiClient, "_discover_data_files") - def test_fetch_inspections_with_file_url(self, mock_discover, mock_download): - """Test fetching inspections with direct file URL.""" - mock_download.return_value = _create_test_xml_inspections() + def test_fetch_inspections_with_file_url(self): + xml_content, rows = build_proverki_xml(count=2) - with ProverkiClient() as client: + with TestHTTPServer() as server: + server.add_bytes( + "/files/inspections.xml", xml_content, content_type="text/xml" + ) + client = ProverkiClient( + host=_host_from_base_url(server.base_url), + scheme="http", + use_playwright=False, + http_adapter=server.adapter, + ) inspections = client.fetch_inspections( - file_url="https://proverki.gov.ru/opendata/test.xml" + file_url=f"{server.base_url}/files/inspections.xml" ) - self.assertEqual(len(inspections), 2) - mock_discover.assert_not_called() # Should not discover files when URL provided - - @patch.object(ProverkiClient, "_discover_data_files") - def test_fetch_inspections_no_files(self, mock_discover): - """Test returns empty list when no files found.""" - mock_discover.return_value = [] - - with ProverkiClient() as client: - inspections = client.fetch_inspections(year=2025) + self.assertEqual(len(inspections), len(rows)) + def test_fetch_inspections_no_files(self): + client = ProverkiClient(use_playwright=False) + inspections = client.fetch_inspections() self.assertEqual(inspections, []) -@tag("integration", "slow", "network") +@tag("integration", "slow") class ProverkiClientIntegrationTest(TestCase): - """ - Интеграционные тесты для клиента proverki.gov.ru. + """Integration test using local HTTP server for proverki.gov.ru.""" - ВНИМАНИЕ: Эти тесты делают реальные HTTP запросы к внешним серверам. - Запускать с тегом: python manage.py test --tag=integration - """ + def test_fetch_inspections_local_server(self): + xml_content, rows = build_proverki_xml(count=3) - def test_fetch_inspections_real_data(self): - """ - Интеграционный тест: реальная загрузка проверок с proverki.gov.ru. - """ - try: - with ProverkiClient(timeout=120) as client: - inspections = client.fetch_inspections(year=2025) + with TestHTTPServer() as server: + server.add_bytes( + "/files/inspections.xml", xml_content, content_type="text/xml" + ) + client = ProverkiClient( + host=_host_from_base_url(server.base_url), + scheme="http", + use_playwright=False, + http_adapter=server.adapter, + ) + inspections = client.fetch_inspections( + file_url=f"{server.base_url}/files/inspections.xml" + ) - # Проверяем что данные получены - self.assertIsInstance(inspections, list) - - # Если данные есть - проверяем структуру - if inspections: - insp = inspections[0] - self.assertIsInstance(insp, Inspection) - self.assertIsNotNone(insp.registration_number) - self.assertIsNotNone(insp.inn) - - # Логируем для информации - print(f"\n[INTEGRATION] Loaded {len(inspections)} inspections") - print(f"[INTEGRATION] First inspection: {insp.registration_number}") - print(f"[INTEGRATION] Organisation: {insp.organisation_name}") - print(f"[INTEGRATION] Control authority: {insp.control_authority}") - else: - print( - "\n[INTEGRATION] No inspections found " - "(API may be unavailable or data format changed)" - ) - - except HTTPClientError as e: - # API может быть недоступен - self.skipTest(f"External API unavailable: {e}") + self.assertEqual(len(inspections), len(rows)) diff --git a/tests/apps/parsers/test_fns_upload.py b/tests/apps/parsers/test_fns_upload.py new file mode 100644 index 0000000..7b7f172 --- /dev/null +++ b/tests/apps/parsers/test_fns_upload.py @@ -0,0 +1,230 @@ +"""Integration tests for FNS upload flow (no mocks).""" + +import io +import os +import tempfile +import time + +from apps.parsers.models import FinancialReport, FinancialReportLine +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from django.urls import reverse +from openpyxl import Workbook +from rest_framework import status +from rest_framework.test import APITestCase + +from tests.apps.user.factories import UserFactory +from tests.utils.fixtures import fake + + +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +def _build_fns_excel_bytes() -> bytes: + wb = Workbook() + ws = wb.active + year = fake.random_int(min=2020, max=2025) + ws.append(["Форма №1", None, year, None]) + ws.append([None, "Код", "Начало", "Конец"]) + ws.append([fake.word(), _digits(4), fake.random_int(10, 999), fake.random_int(10, 999)]) + buf = io.BytesIO() + wb.save(buf) + wb.close() + return buf.getvalue() + + +class FNSUploadIntegrationTest(APITestCase): + """Tests real upload + processing of FNS files.""" + + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + self.upload_url = reverse("api_v1:fns:fns-upload") + + def _dirs(self, base_dir: str) -> tuple[str, str, str]: + watch_dir = os.path.join(base_dir, "watch") + processed_dir = os.path.join(base_dir, "processed") + failed_dir = os.path.join(base_dir, "failed") + return watch_dir, processed_dir, failed_dir + + def test_upload_processes_file_and_moves_to_processed(self): + content = _build_fns_excel_bytes() + external_id = _digits(5) + ogrn = _digits(13) + filename = f"fin_{external_id}_{ogrn}.xlsx" + upload = SimpleUploadedFile( + filename, + content, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + watch_dir, processed_dir, failed_dir = self._dirs(tmpdir) + with override_settings( + FNS_WATCH_DIRECTORY=watch_dir, + FNS_PROCESSED_DIRECTORY=processed_dir, + FNS_FAILED_DIRECTORY=failed_dir, + ): + response = self.client.post( + self.upload_url, {"files": [upload]}, format="multipart" + ) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.data["queued"], 1) + self.assertEqual(response.data["skipped"], 0) + + self.assertEqual(FinancialReport.objects.count(), 1) + report = FinancialReport.objects.first() + self.assertEqual(report.external_id, external_id) + self.assertEqual(report.ogrn, ogrn) + self.assertTrue( + FinancialReportLine.objects.filter(report=report).exists() + ) + + processed_path = os.path.join(processed_dir, filename) + self.assertTrue(os.path.exists(processed_path)) + self.assertFalse(os.path.exists(os.path.join(watch_dir, filename))) + self.assertFalse( + os.path.exists(os.path.join(watch_dir, f"{filename}.lock")) + ) + + def test_upload_duplicate_is_skipped(self): + content = _build_fns_excel_bytes() + external_id = _digits(3) + ogrn = _digits(13) + filename = f"fin_{external_id}_{ogrn}.xlsx" + upload1 = SimpleUploadedFile( + filename, + content, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + upload2 = SimpleUploadedFile( + filename, + content, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + watch_dir, processed_dir, failed_dir = self._dirs(tmpdir) + with override_settings( + FNS_WATCH_DIRECTORY=watch_dir, + FNS_PROCESSED_DIRECTORY=processed_dir, + FNS_FAILED_DIRECTORY=failed_dir, + ): + first = self.client.post( + self.upload_url, {"files": [upload1]}, format="multipart" + ) + second = self.client.post( + self.upload_url, {"files": [upload2]}, format="multipart" + ) + + self.assertEqual(first.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(second.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(second.data["queued"], 0) + self.assertEqual(second.data["skipped"], 1) + + self.assertEqual(FinancialReport.objects.count(), 1) + self.assertFalse(os.path.exists(os.path.join(watch_dir, filename))) + self.assertFalse( + os.path.exists(os.path.join(watch_dir, f"{filename}.lock")) + ) + + def test_upload_invalid_filename_rejected(self): + content = _build_fns_excel_bytes() + upload = SimpleUploadedFile( + f"{fake.word()}_{fake.random_int()}.xlsx", + content, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + watch_dir, processed_dir, failed_dir = self._dirs(tmpdir) + with override_settings( + FNS_WATCH_DIRECTORY=watch_dir, + FNS_PROCESSED_DIRECTORY=processed_dir, + FNS_FAILED_DIRECTORY=failed_dir, + ): + response = self.client.post( + self.upload_url, {"files": [upload]}, format="multipart" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(FinancialReport.objects.count(), 0) + + def test_upload_skips_when_lock_is_fresh(self): + content = _build_fns_excel_bytes() + external_id = _digits(5) + ogrn = _digits(13) + filename = f"fin_{external_id}_{ogrn}.xlsx" + upload = SimpleUploadedFile( + filename, + content, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + watch_dir, processed_dir, failed_dir = self._dirs(tmpdir) + os.makedirs(watch_dir, exist_ok=True) + lock_path = os.path.join(watch_dir, f"{filename}.lock") + with open(lock_path, "w") as handle: + handle.write("lock") + now = time.time() + os.utime(lock_path, (now, now)) + + with override_settings( + FNS_WATCH_DIRECTORY=watch_dir, + FNS_PROCESSED_DIRECTORY=processed_dir, + FNS_FAILED_DIRECTORY=failed_dir, + FNS_LOCK_TTL_SECONDS=3600, + ): + response = self.client.post( + self.upload_url, {"files": [upload]}, format="multipart" + ) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.data["queued"], 0) + self.assertEqual(response.data["skipped"], 1) + + def test_upload_skips_when_file_already_exists(self): + content = _build_fns_excel_bytes() + external_id = _digits(5) + ogrn = _digits(13) + filename = f"fin_{external_id}_{ogrn}.xlsx" + upload = SimpleUploadedFile( + filename, + content, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + watch_dir, processed_dir, failed_dir = self._dirs(tmpdir) + os.makedirs(watch_dir, exist_ok=True) + existing_path = os.path.join(watch_dir, filename) + with open(existing_path, "wb") as handle: + handle.write(b"existing") + + with override_settings( + FNS_WATCH_DIRECTORY=watch_dir, + FNS_PROCESSED_DIRECTORY=processed_dir, + FNS_FAILED_DIRECTORY=failed_dir, + ): + response = self.client.post( + self.upload_url, {"files": [upload]}, format="multipart" + ) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.data["queued"], 0) + self.assertEqual(response.data["skipped"], 1) diff --git a/tests/apps/parsers/test_models.py b/tests/apps/parsers/test_models.py index cc057c4..30b57ee 100644 --- a/tests/apps/parsers/test_models.py +++ b/tests/apps/parsers/test_models.py @@ -13,6 +13,11 @@ from .factories import ( ManufacturerRecordFactory, ParserLoadLogFactory, ProxyFactory, + fake, + generate_certificate_number, + generate_company_name, + generate_inn_legal, + generate_proxy_address, ) @@ -30,11 +35,12 @@ class ProxyModelTest(TestCase): def test_proxy_str(self): """Test proxy string representation.""" - proxy = ProxyFactory(address="http://proxy:8080", is_active=True) - self.assertEqual(str(proxy), "http://proxy:8080 (active)") + address = generate_proxy_address() + proxy = ProxyFactory(address=address, is_active=True) + self.assertEqual(str(proxy), f"{address} (active)") proxy.is_active = False - self.assertEqual(str(proxy), "http://proxy:8080 (inactive)") + self.assertEqual(str(proxy), f"{address} (inactive)") def test_proxy_ordering(self): """Test proxy ordering by fail_count.""" @@ -49,12 +55,13 @@ class ProxyModelTest(TestCase): def test_proxy_unique_address(self): """Test proxy address uniqueness.""" - ProxyFactory(address="http://unique:8080") + address = generate_proxy_address() + ProxyFactory(address=address) from django.db import IntegrityError with self.assertRaises(IntegrityError): - ProxyFactory(address="http://unique:8080") + ProxyFactory(address=address) class ParserLoadLogModelTest(TestCase): @@ -70,11 +77,15 @@ class ParserLoadLogModelTest(TestCase): def test_load_log_str(self): """Test load log string representation.""" + batch_id = fake.random_int(min=1, max=999) + records_count = fake.random_int(min=1, max=5000) log = ParserLoadLogFactory( - batch_id=42, source=ParserLoadLog.Source.INDUSTRIAL, records_count=100 + batch_id=batch_id, + source=ParserLoadLog.Source.INDUSTRIAL, + records_count=records_count, ) - self.assertIn("42", str(log)) - self.assertIn("100", str(log)) + self.assertIn(str(batch_id), str(log)) + self.assertIn(str(records_count), str(log)) def test_load_log_timestamps(self): """Test load log has timestamps from mixin.""" @@ -98,11 +109,14 @@ class IndustrialCertificateRecordModelTest(TestCase): def test_certificate_str(self): """Test certificate string representation.""" + certificate_number = generate_certificate_number() + organisation_name = generate_company_name() cert = IndustrialCertificateRecordFactory( - certificate_number="CERT-123", organisation_name="Test Company LLC" + certificate_number=certificate_number, + organisation_name=organisation_name, ) - self.assertIn("CERT-123", str(cert)) - self.assertIn("Test Company", str(cert)) + self.assertIn(certificate_number, str(cert)) + self.assertIn(organisation_name[:50], str(cert)) def test_certificate_timestamps(self): """Test certificate has timestamps from mixin.""" @@ -126,11 +140,14 @@ class ManufacturerRecordModelTest(TestCase): def test_manufacturer_str(self): """Test manufacturer string representation.""" + inn = generate_inn_legal() + company_name = generate_company_name() manufacturer = ManufacturerRecordFactory( - inn="1234567890", full_legal_name="Test Manufacturing Company" + inn=inn, + full_legal_name=company_name, ) - self.assertIn("1234567890", str(manufacturer)) - self.assertIn("Test Manufacturing", str(manufacturer)) + self.assertIn(inn, str(manufacturer)) + self.assertIn(company_name[:50], str(manufacturer)) def test_manufacturer_timestamps(self): """Test manufacturer has timestamps from mixin.""" diff --git a/tests/apps/parsers/test_services.py b/tests/apps/parsers/test_services.py index a12d7a1..91982f2 100644 --- a/tests/apps/parsers/test_services.py +++ b/tests/apps/parsers/test_services.py @@ -1,5 +1,6 @@ """Tests for parsers services.""" +from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer from apps.parsers.clients.proverki.schemas import Inspection from apps.parsers.models import ( @@ -16,8 +17,10 @@ from apps.parsers.services import ( ParserLoadLogService, ProxyService, ) -from django.test import TestCase -from faker import Faker +from django.test import TestCase, tag +from tests.utils import TestHTTPServer +from tests.utils.fixtures import build_minpromtorg_certificates_excel, fake +from urllib.parse import urlparse from .factories import ( IndustrialCertificateRecordFactory, @@ -27,8 +30,13 @@ from .factories import ( ProxyFactory, ) -fake = Faker("ru_RU") +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +def _proxy_address() -> str: + return f"http://{fake.ipv4()}:{fake.port_number()}" class ProxyServiceTest(TestCase): """Tests for ProxyService.""" @@ -66,7 +74,7 @@ class ProxyServiceTest(TestCase): def test_mark_used(self): """Test marking proxy as used updates timestamp.""" - proxy = ProxyFactory() + proxy = ProxyFactory(last_used_at=None) self.assertIsNone(proxy.last_used_at) ProxyService.mark_used(proxy.address) @@ -94,8 +102,8 @@ class ProxyServiceTest(TestCase): def test_add_proxy(self): """Test adding new proxy.""" - address = "http://new-proxy:8080" - description = "Test proxy" + address = _proxy_address() + description = fake.sentence(nb_words=3) proxy = ProxyService.add_proxy(address, description) @@ -105,21 +113,19 @@ class ProxyServiceTest(TestCase): def test_add_proxy_idempotent(self): """Test adding existing proxy returns existing record.""" - address = "http://existing:8080" - existing = ProxyFactory(address=address, description="Original") + address = _proxy_address() + existing_description = fake.sentence(nb_words=3) + existing = ProxyFactory(address=address, description=existing_description) - proxy = ProxyService.add_proxy(address, "New description") + new_description = fake.sentence(nb_words=3) + proxy = ProxyService.add_proxy(address, new_description) self.assertEqual(proxy.id, existing.id) - self.assertEqual(proxy.description, "Original") # Not updated + self.assertEqual(proxy.description, existing_description) # Not updated def test_add_proxies(self): """Test bulk adding proxies.""" - addresses = [ - "http://proxy1:8080", - "http://proxy2:8080", - "http://proxy3:8080", - ] + addresses = [_proxy_address() for _ in range(3)] created = ProxyService.add_proxies(addresses) @@ -128,10 +134,14 @@ class ProxyServiceTest(TestCase): def test_add_proxies_skips_existing(self): """Test bulk add skips existing proxies.""" - ProxyFactory(address="http://existing:8080") + existing_address = _proxy_address() + new_address = _proxy_address() + while new_address == existing_address: + new_address = _proxy_address() + ProxyFactory(address=existing_address) addresses = [ - "http://existing:8080", # Already exists - "http://new:8080", + existing_address, # Already exists + new_address, ] created = ProxyService.add_proxies(addresses) @@ -194,11 +204,12 @@ class ParserLoadLogServiceTest(TestCase): """Test marking log as failed.""" log = ParserLoadLogFactory(status="success") - ParserLoadLogService.mark_failed(log, "Connection error") + error_message = fake.sentence(nb_words=4) + ParserLoadLogService.mark_failed(log, error_message) log.refresh_from_db() self.assertEqual(log.status, "failed") - self.assertEqual(log.error_message, "Connection error") + self.assertEqual(log.error_message, error_message) def test_update_records_count(self): """Test updating records count.""" @@ -222,13 +233,13 @@ class IndustrialCertificateServiceTest(TestCase): """Test saving certificates from dataclass.""" certificates = [ IndustrialCertificate( - issue_date="2024-01-01", - certificate_number=f"CERT-{i}", - expiry_date="2025-01-01", - certificate_file_url=f"https://example.com/cert{i}.pdf", - organisation_name=f"Company {i}", - inn=f"123456789{i}", - ogrn=f"123456789012{i}", + issue_date=str(fake.date()), + certificate_number=fake.bothify(text="??-####-#####"), + expiry_date=str(fake.date()), + certificate_file_url=fake.url(), + organisation_name=fake.company(), + inn=_digits(10), + ogrn=_digits(13), ) for i in range(5) ] @@ -242,13 +253,13 @@ class IndustrialCertificateServiceTest(TestCase): """Test saving certificates in chunks.""" certificates = [ IndustrialCertificate( - issue_date="2024-01-01", - certificate_number=f"CERT-{i}", - expiry_date="2025-01-01", - certificate_file_url=f"https://example.com/cert{i}.pdf", - organisation_name=f"Company {i}", - inn=f"12345678{i:02d}", - ogrn=f"1234567890{i:03d}", + issue_date=str(fake.date()), + certificate_number=fake.bothify(text="??-####-#####"), + expiry_date=str(fake.date()), + certificate_file_url=fake.url(), + organisation_name=fake.company(), + inn=_digits(10), + ogrn=_digits(13), ) for i in range(10) ] @@ -261,44 +272,48 @@ class IndustrialCertificateServiceTest(TestCase): def test_find_by_inn(self): """Test finding certificates by INN.""" + inn_a = _digits(10) + inn_b = _digits(10) IndustrialCertificateRecordFactory( - inn="1111111111", certificate_number="CERT-A1", load_batch=1 + inn=inn_a, certificate_number=fake.bothify(text="CERT-####"), load_batch=1 ) IndustrialCertificateRecordFactory( - inn="1111111111", certificate_number="CERT-A2", load_batch=2 + inn=inn_a, certificate_number=fake.bothify(text="CERT-####"), load_batch=2 ) IndustrialCertificateRecordFactory( - inn="2222222222", certificate_number="CERT-B1", load_batch=1 + inn=inn_b, certificate_number=fake.bothify(text="CERT-####"), load_batch=1 ) - results = IndustrialCertificateService.find_by_inn("1111111111") + results = IndustrialCertificateService.find_by_inn(inn_a) self.assertEqual(results.count(), 2) results_batch1 = IndustrialCertificateService.find_by_inn( - "1111111111", batch_id=1 + inn_a, batch_id=1 ) self.assertEqual(results_batch1.count(), 1) def test_find_by_certificate_number(self): """Test finding certificate by number.""" - IndustrialCertificateRecordFactory(certificate_number="CERT-UNIQUE") - IndustrialCertificateRecordFactory(certificate_number="CERT-OTHER") + unique_number = fake.bothify(text="CERT-#####") + IndustrialCertificateRecordFactory(certificate_number=unique_number) + IndustrialCertificateRecordFactory(certificate_number=fake.bothify(text="CERT-#####")) - results = IndustrialCertificateService.find_by_certificate_number("CERT-UNIQUE") + results = IndustrialCertificateService.find_by_certificate_number(unique_number) self.assertEqual(results.count(), 1) def test_save_certificates_deduplication(self): """Test saving certificates skips duplicates by certificate_number.""" # Create initial certificate + cert_number = fake.bothify(text="CERT-DEDUP-#####") initial = [ IndustrialCertificate( - issue_date="2024-01-01", - certificate_number="CERT-DEDUP-001", - expiry_date="2025-01-01", - certificate_file_url="https://example.com/old.pdf", - organisation_name="Old Company Name", - inn="1234567890", - ogrn="1234567890123", + issue_date=str(fake.date()), + certificate_number=cert_number, + expiry_date=str(fake.date()), + certificate_file_url=fake.url(), + organisation_name=fake.company(), + inn=_digits(10), + ogrn=_digits(13), ) ] count1 = IndustrialCertificateService.save_certificates(initial, batch_id=1) @@ -308,13 +323,13 @@ class IndustrialCertificateServiceTest(TestCase): # Try to save with same certificate_number - should be skipped duplicate = [ IndustrialCertificate( - issue_date="2024-06-01", - certificate_number="CERT-DEDUP-001", # Same number - will be skipped - expiry_date="2026-01-01", - certificate_file_url="https://example.com/new.pdf", - organisation_name="New Company Name", - inn="9999999999", - ogrn="9999999999999", + issue_date=str(fake.date()), + certificate_number=cert_number, # Same number - will be skipped + expiry_date=str(fake.date()), + certificate_file_url=fake.url(), + organisation_name=fake.company(), + inn=_digits(10), + ogrn=_digits(13), ) ] IndustrialCertificateService.save_certificates(duplicate, batch_id=2) @@ -324,8 +339,8 @@ class IndustrialCertificateServiceTest(TestCase): # Verify original data preserved record = IndustrialCertificateRecord.objects.first() - self.assertEqual(record.organisation_name, "Old Company Name") - self.assertEqual(record.inn, "1234567890") + self.assertEqual(record.organisation_name, initial[0].organisation_name) + self.assertEqual(record.inn, initial[0].inn) self.assertEqual(record.load_batch, 1) # Original batch @@ -341,10 +356,10 @@ class ManufacturerServiceTest(TestCase): """Test saving manufacturers from dataclass.""" manufacturers = [ Manufacturer( - full_legal_name=f"Company {i} LLC", - inn=f"123456789{i}", - ogrn=f"123456789012{i}", - address=f"Address {i}", + full_legal_name=fake.company(), + inn=_digits(10), + ogrn=_digits(13), + address=fake.address().replace("\n", ", "), ) for i in range(5) ] @@ -358,10 +373,10 @@ class ManufacturerServiceTest(TestCase): """Test saving manufacturers in chunks.""" manufacturers = [ Manufacturer( - full_legal_name=f"Company {i}", - inn=f"12345678{i:02d}", - ogrn=f"1234567890{i:03d}", - address=f"Address {i}", + full_legal_name=fake.company(), + inn=_digits(10), + ogrn=_digits(13), + address=fake.address().replace("\n", ", "), ) for i in range(10) ] @@ -374,41 +389,50 @@ class ManufacturerServiceTest(TestCase): def test_find_by_inn(self): """Test finding manufacturers by INN.""" - ManufacturerRecordFactory(inn="1111111111", load_batch=1) - ManufacturerRecordFactory(inn="2222222222", load_batch=1) - ManufacturerRecordFactory(inn="3333333333", load_batch=2) + inn_target = _digits(10) + inn_other = _digits(10) + inn_third = _digits(10) + ManufacturerRecordFactory(inn=inn_target, load_batch=1) + ManufacturerRecordFactory(inn=inn_other, load_batch=1) + ManufacturerRecordFactory(inn=inn_third, load_batch=2) - results = ManufacturerService.find_by_inn("1111111111") + results = ManufacturerService.find_by_inn(inn_target) self.assertEqual(results.count(), 1) def test_find_by_inn_with_batch_filter(self): """Test finding manufacturers by INN with batch filter.""" - ManufacturerRecordFactory(inn="4444444444", load_batch=1) - ManufacturerRecordFactory(inn="5555555555", load_batch=2) + inn_value = _digits(10) + ManufacturerRecordFactory(inn=inn_value, load_batch=1) + ManufacturerRecordFactory(inn=_digits(10), load_batch=2) - results_batch1 = ManufacturerService.find_by_inn("4444444444", batch_id=1) + results_batch1 = ManufacturerService.find_by_inn(inn_value, batch_id=1) self.assertEqual(results_batch1.count(), 1) - results_batch2 = ManufacturerService.find_by_inn("4444444444", batch_id=2) + results_batch2 = ManufacturerService.find_by_inn(inn_value, batch_id=2) self.assertEqual(results_batch2.count(), 0) def test_find_by_ogrn(self): """Test finding manufacturers by OGRN.""" - ManufacturerRecordFactory(ogrn="1234567890123") - ManufacturerRecordFactory(ogrn="9999999999999") + ogrn_target = _digits(13) + ManufacturerRecordFactory(ogrn=ogrn_target) + ManufacturerRecordFactory(ogrn=_digits(13)) - results = ManufacturerService.find_by_ogrn("1234567890123") + results = ManufacturerService.find_by_ogrn(ogrn_target) self.assertEqual(results.count(), 1) def test_save_manufacturers_deduplication(self): """Test saving manufacturers skips duplicates by INN.""" # Create initial manufacturer + inn_value = _digits(10) + ogrn_value = _digits(13) + address_value = fake.address().replace("\n", ", ") + company_name = fake.company() initial = [ Manufacturer( - full_legal_name="Old Company Name LLC", - inn="7777777777", - ogrn="1234567890123", - address="Old Address", + full_legal_name=company_name, + inn=inn_value, + ogrn=ogrn_value, + address=address_value, ) ] count1 = ManufacturerService.save_manufacturers(initial, batch_id=1) @@ -418,10 +442,10 @@ class ManufacturerServiceTest(TestCase): # Try to save with same INN - should be skipped duplicate = [ Manufacturer( - full_legal_name="New Company Name LLC", - inn="7777777777", # Same INN - will be skipped - ogrn="9999999999999", - address="New Address", + full_legal_name=fake.company(), + inn=inn_value, # Same INN - will be skipped + ogrn=_digits(13), + address=fake.address().replace("\n", ", "), ) ] ManufacturerService.save_manufacturers(duplicate, batch_id=2) @@ -431,9 +455,9 @@ class ManufacturerServiceTest(TestCase): # Verify original data preserved record = ManufacturerRecord.objects.first() - self.assertEqual(record.full_legal_name, "Old Company Name LLC") - self.assertEqual(record.ogrn, "1234567890123") - self.assertEqual(record.address, "Old Address") + self.assertEqual(record.full_legal_name, company_name) + self.assertEqual(record.ogrn, ogrn_value) + self.assertEqual(record.address, address_value) self.assertEqual(record.load_batch, 1) # Original batch @@ -449,18 +473,18 @@ class InspectionServiceTest(TestCase): """Test saving inspections from dataclass.""" inspections = [ Inspection( - registration_number=f"77202400000{i}", - inn=f"770{i}234567", - ogrn=f"102770000000{i}", - organisation_name=f"Компания {i}", - control_authority="Роспотребнадзор", - inspection_type="плановая", - inspection_form="документарная", - start_date="2024-01-15", - end_date="2024-01-30", - status="завершена", - legal_basis="294-ФЗ", - result="нарушения не выявлены", + registration_number=_digits(12), + inn=_digits(10), + ogrn=_digits(13), + organisation_name=fake.company(), + control_authority=fake.company(), + inspection_type=fake.word(), + inspection_form=fake.word(), + start_date=str(fake.date()), + end_date=str(fake.date()), + status=fake.word(), + legal_basis=fake.sentence(nb_words=3), + result=fake.sentence(nb_words=3), ) for i in range(5) ] @@ -474,17 +498,17 @@ class InspectionServiceTest(TestCase): """Test saving inspections in chunks.""" inspections = [ Inspection( - registration_number=f"7720240000{i:02d}", - inn=f"770{i:02d}34567", - ogrn=f"10277000000{i:02d}", - organisation_name=f"Компания {i}", - control_authority="Ростехнадзор", - inspection_type="внеплановая", - inspection_form="выездная", - start_date="2024-02-01", - end_date="2024-02-15", - status="завершена", - legal_basis="248-ФЗ", + registration_number=_digits(12), + inn=_digits(10), + ogrn=_digits(13), + organisation_name=fake.company(), + control_authority=fake.company(), + inspection_type=fake.word(), + inspection_form=fake.word(), + start_date=str(fake.date()), + end_date=str(fake.date()), + status=fake.word(), + legal_basis=fake.sentence(nb_words=3), ) for i in range(10) ] @@ -497,57 +521,74 @@ class InspectionServiceTest(TestCase): def test_find_by_inn(self): """Test finding inspections by INN.""" - InspectionRecordFactory(inn="1111111111", load_batch=1) - InspectionRecordFactory(inn="1111111111", load_batch=2) - InspectionRecordFactory(inn="2222222222", load_batch=1) + inn_value = _digits(10) + InspectionRecordFactory(inn=inn_value, load_batch=1) + InspectionRecordFactory(inn=inn_value, load_batch=2) + InspectionRecordFactory(inn=_digits(10), load_batch=1) - results = InspectionService.find_by_inn("1111111111") + results = InspectionService.find_by_inn(inn_value) self.assertEqual(results.count(), 2) - results_batch1 = InspectionService.find_by_inn("1111111111", batch_id=1) + results_batch1 = InspectionService.find_by_inn(inn_value, batch_id=1) self.assertEqual(results_batch1.count(), 1) def test_find_by_registration_number(self): """Test finding inspection by registration number.""" - InspectionRecordFactory(registration_number="772024000001") - InspectionRecordFactory(registration_number="772024000002") + target_number = _digits(12) + other_number = _digits(12) + InspectionRecordFactory(registration_number=target_number) + InspectionRecordFactory(registration_number=other_number) - results = InspectionService.find_by_registration_number("772024000001") + results = InspectionService.find_by_registration_number(target_number) self.assertEqual(results.count(), 1) def test_find_by_control_authority(self): """Test finding inspections by control authority.""" - InspectionRecordFactory(control_authority="Роспотребнадзор", load_batch=1) - InspectionRecordFactory( - control_authority="Управление Роспотребнадзора по г. Москве", load_batch=1 - ) - InspectionRecordFactory(control_authority="Ростехнадзор", load_batch=1) + authority_key = fake.word() + authority_match_1 = f"{fake.company()} {authority_key}" + authority_match_2 = f"{authority_key} {fake.company()}" + authority_other = fake.company() + InspectionRecordFactory(control_authority=authority_match_1, load_batch=1) + InspectionRecordFactory(control_authority=authority_match_2, load_batch=1) + InspectionRecordFactory(control_authority=authority_other, load_batch=1) - results = InspectionService.find_by_control_authority("Роспотребнадзор") + results = InspectionService.find_by_control_authority(authority_key) self.assertEqual(results.count(), 2) results_batch1 = InspectionService.find_by_control_authority( - "Роспотребнадзор", batch_id=1 + authority_key, batch_id=1 ) self.assertEqual(results_batch1.count(), 2) def test_save_inspections_deduplication(self): """Test saving inspections skips duplicates by registration_number.""" # Create initial inspection + reg_number = _digits(12) + inn_value = _digits(10) + ogrn_value = _digits(13) + org_name = fake.company() + control_authority = fake.company() + inspection_type = fake.word() + inspection_form = fake.word() + start_date = str(fake.date()) + end_date = str(fake.date()) + status = fake.word() + legal_basis = fake.sentence(nb_words=3) + result_text = fake.sentence(nb_words=3) initial = [ Inspection( - registration_number="DEDUP-REG-001", - inn="1234567890", - ogrn="1234567890123", - organisation_name="Old Organisation", - control_authority="Роспотребнадзор", - inspection_type="плановая", - inspection_form="документарная", - start_date="2024-01-01", - end_date="2024-01-15", - status="завершена", - legal_basis="294-ФЗ", - result="нарушения не выявлены", + registration_number=reg_number, + inn=inn_value, + ogrn=ogrn_value, + organisation_name=org_name, + control_authority=control_authority, + inspection_type=inspection_type, + inspection_form=inspection_form, + start_date=start_date, + end_date=end_date, + status=status, + legal_basis=legal_basis, + result=result_text, ) ] count1 = InspectionService.save_inspections(initial, batch_id=1) @@ -557,18 +598,18 @@ class InspectionServiceTest(TestCase): # Try to save with same registration_number - should be skipped duplicate = [ Inspection( - registration_number="DEDUP-REG-001", # Same number - will be skipped - inn="9999999999", - ogrn="9999999999999", - organisation_name="New Organisation", - control_authority="Ростехнадзор", - inspection_type="внеплановая", - inspection_form="выездная", - start_date="2024-06-01", - end_date="2024-06-30", - status="в процессе", - legal_basis="248-ФЗ", - result="выявлены нарушения", + registration_number=reg_number, # Same number - will be skipped + inn=_digits(10), + ogrn=_digits(13), + organisation_name=fake.company(), + control_authority=fake.company(), + inspection_type=fake.word(), + inspection_form=fake.word(), + start_date=str(fake.date()), + end_date=str(fake.date()), + status=fake.word(), + legal_basis=fake.sentence(nb_words=3), + result=fake.sentence(nb_words=3), ) ] InspectionService.save_inspections(duplicate, batch_id=2) @@ -578,19 +619,14 @@ class InspectionServiceTest(TestCase): # Verify original data preserved record = InspectionRecord.objects.first() - self.assertEqual(record.organisation_name, "Old Organisation") - self.assertEqual(record.inn, "1234567890") - self.assertEqual(record.control_authority, "Роспотребнадзор") - self.assertEqual(record.status, "завершена") + self.assertEqual(record.organisation_name, org_name) + self.assertEqual(record.inn, inn_value) + self.assertEqual(record.control_authority, control_authority) + self.assertEqual(record.status, status) self.assertEqual(record.load_batch, 1) # Original batch -from apps.parsers.clients.base import HTTPClientError -from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient -from django.test import tag - - -@tag("integration", "slow", "network", "e2e") +@tag("integration", "slow", "e2e") class EndToEndIntegrationTest(TestCase): """ End-to-end интеграционные тесты полного flow. @@ -608,72 +644,96 @@ class EndToEndIntegrationTest(TestCase): 3. Сохраняем первые N записей в БД 4. Проверяем что данные корректно сохранились """ - try: - # 1. Загружаем данные с API - print("\n[E2E] Step 1: Fetching certificates from API...") - with IndustrialProductionClient(timeout=120) as client: + # 1. Загружаем данные через локальный HTTP сервер (без внешнего API) + print("\n[E2E] Step 1: Fetching certificates from local API...") + excel_bytes, rows = build_minpromtorg_certificates_excel(count=5) + date_str = fake.date_between(start_date="-30d", end_date="today").strftime( + "%Y%m%d" + ) + file_name = f"data_resolutions_{date_str}.xlsx" + + with TestHTTPServer() as server: + server.add_json( + "/api/kss-document-preview", + { + "data": [ + { + "name": IndustrialProductionClient().query, + "files": [ + {"name": file_name, "url": f"/files/{file_name}"} + ], + } + ] + }, + ) + server.add_bytes(f"/files/{file_name}", excel_bytes) + host = urlparse(server.base_url) + client_host = ( + f"{host.hostname}:{host.port}" if host.port else host.hostname + ) + with IndustrialProductionClient( + host=client_host, + scheme="http", + timeout=30, + http_adapter=server.adapter, + ) as client: all_certificates = client.fetch_certificates() - if not all_certificates: - self.skipTest("No certificates returned from API") + self.assertEqual(len(all_certificates), len(rows)) + print(f"[E2E] Loaded {len(all_certificates)} certificates from local API") - print(f"[E2E] Loaded {len(all_certificates)} certificates from API") + # Берём все для теста + certificates = all_certificates - # Берём только первые 100 для теста - certificates = all_certificates[:100] + # 2. Создаём batch_id и лог + print("[E2E] Step 2: Creating load log...") + batch_id = ParserLoadLogService.get_next_batch_id( + ParserLoadLog.Source.INDUSTRIAL + ) + log = ParserLoadLogService.create_load_log( + source=ParserLoadLog.Source.INDUSTRIAL, + batch_id=batch_id, + records_count=0, + ) + print(f"[E2E] Created batch_id={batch_id}") - # 2. Создаём batch_id и лог - print("[E2E] Step 2: Creating load log...") - batch_id = ParserLoadLogService.get_next_batch_id( - ParserLoadLog.Source.INDUSTRIAL - ) - log = ParserLoadLogService.create_load_log( - source=ParserLoadLog.Source.INDUSTRIAL, - batch_id=batch_id, - records_count=0, - ) - print(f"[E2E] Created batch_id={batch_id}") + # 3. Сохраняем в БД + print("[E2E] Step 3: Saving certificates to database...") + saved_count = IndustrialCertificateService.save_certificates( + certificates, batch_id=batch_id + ) + ParserLoadLogService.update_records_count(log, saved_count) - # 3. Сохраняем в БД - print("[E2E] Step 3: Saving certificates to database...") - saved_count = IndustrialCertificateService.save_certificates( - certificates, batch_id=batch_id - ) - ParserLoadLogService.update_records_count(log, saved_count) + print(f"[E2E] Saved {saved_count} certificates") - print(f"[E2E] Saved {saved_count} certificates") + # 4. Проверяем результат + print("[E2E] Step 4: Verifying saved data...") - # 4. Проверяем результат - print("[E2E] Step 4: Verifying saved data...") + # Проверяем количество + db_count = IndustrialCertificateRecord.objects.filter( + load_batch=batch_id + ).count() + self.assertEqual(db_count, saved_count) + self.assertEqual(db_count, len(certificates)) - # Проверяем количество - db_count = IndustrialCertificateRecord.objects.filter( - load_batch=batch_id - ).count() - self.assertEqual(db_count, saved_count) - self.assertEqual(db_count, len(certificates)) + # Проверяем первую запись + first_cert = certificates[0] + db_record = IndustrialCertificateRecord.objects.filter( + load_batch=batch_id, + certificate_number=first_cert.certificate_number, + ).first() - # Проверяем первую запись - first_cert = certificates[0] - db_record = IndustrialCertificateRecord.objects.filter( - load_batch=batch_id, - certificate_number=first_cert.certificate_number, - ).first() + self.assertIsNotNone(db_record) + self.assertEqual(db_record.inn, first_cert.inn) + self.assertEqual(db_record.ogrn, first_cert.ogrn) + self.assertEqual(db_record.organisation_name, first_cert.organisation_name) - self.assertIsNotNone(db_record) - self.assertEqual(db_record.inn, first_cert.inn) - self.assertEqual(db_record.ogrn, first_cert.ogrn) - self.assertEqual(db_record.organisation_name, first_cert.organisation_name) + # Проверяем лог + log.refresh_from_db() + self.assertEqual(log.records_count, saved_count) + self.assertEqual(log.status, "success") - # Проверяем лог - log.refresh_from_db() - self.assertEqual(log.records_count, saved_count) - self.assertEqual(log.status, "success") - - print("[E2E] ✅ All checks passed!") - print(f"[E2E] Sample record: {db_record.certificate_number}") - print(f"[E2E] Organisation: {db_record.organisation_name}") - print(f"[E2E] INN: {db_record.inn}, OGRN: {db_record.ogrn}") - - except HTTPClientError as e: - self.skipTest(f"External API unavailable: {e}") + print("[E2E] ✅ All checks passed!") + print(f"[E2E] Sample record: {db_record.certificate_number}") + print(f"[E2E] Organisation: {db_record.organisation_name}") + print(f"[E2E] INN: {db_record.inn}, OGRN: {db_record.ogrn}") diff --git a/tests/apps/parsers/test_views.py b/tests/apps/parsers/test_views.py new file mode 100644 index 0000000..c6bc986 --- /dev/null +++ b/tests/apps/parsers/test_views.py @@ -0,0 +1,188 @@ +"""Integration tests for parsers API views (no mocks).""" + +from __future__ import annotations + +import io +import os +import tempfile + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from openpyxl import Workbook +from rest_framework import status +from rest_framework.test import APITestCase + +from apps.parsers.models import FinancialReport, FinancialReportLine, ProcurementRecord +from tests.apps.parsers.factories import ( + IndustrialCertificateRecordFactory, + InspectionRecordFactory, + ManufacturerRecordFactory, + ParserLoadLogFactory, + ProxyFactory, +) +from tests.apps.user.factories import UserFactory +from tests.utils.fixtures import fake + + +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +def _build_fns_excel_bytes() -> bytes: + wb = Workbook() + ws = wb.active + year = fake.random_int(min=2020, max=2025) + ws.append(["Form", None, year, None]) + ws.append([None, "Code", "Start", "End"]) + ws.append([fake.word(), _digits(4), fake.random_int(10, 999), fake.random_int(10, 999)]) + buf = io.BytesIO() + wb.save(buf) + wb.close() + return buf.getvalue() + + +def _create_procurement_record() -> ProcurementRecord: + return ProcurementRecord.objects.create( + load_batch=fake.random_int(min=1, max=1000), + purchase_number=_digits(19), + purchase_name=fake.sentence(nb_words=6), + customer_inn=_digits(10), + customer_kpp=_digits(9), + customer_ogrn=_digits(13), + customer_name=fake.company(), + max_price=str(fake.pydecimal(left_digits=7, right_digits=2, positive=True)), + status=fake.word(), + law_type="44-FZ", + href=fake.url(), + region_code=f"{fake.random_int(min=1, max=99):02d}", + ) + + +class ParsersViewSetTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.admin = UserFactory.create_superuser() + + def test_certificates_list_and_retrieve(self): + record = IndustrialCertificateRecordFactory() + self.client.force_authenticate(self.user) + url = reverse("api_v1:minpromtorg:certificates-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + detail = self.client.get( + reverse("api_v1:minpromtorg:certificates-detail", args=[record.id]) + ) + self.assertEqual(detail.status_code, status.HTTP_200_OK) + + def test_manufacturers_list_and_retrieve(self): + record = ManufacturerRecordFactory() + self.client.force_authenticate(self.user) + url = reverse("api_v1:minpromtorg:manufacturers-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + detail = self.client.get( + reverse("api_v1:minpromtorg:manufacturers-detail", args=[record.id]) + ) + self.assertEqual(detail.status_code, status.HTTP_200_OK) + + def test_inspections_list_and_retrieve(self): + record = InspectionRecordFactory() + self.client.force_authenticate(self.user) + url = reverse("api_v1:proverki:inspections-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + detail = self.client.get( + reverse("api_v1:proverki:inspections-detail", args=[record.id]) + ) + self.assertEqual(detail.status_code, status.HTTP_200_OK) + + def test_procurements_list_and_retrieve(self): + record = _create_procurement_record() + self.client.force_authenticate(self.user) + url = reverse("api_v1:zakupki:procurements-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + detail = self.client.get( + reverse("api_v1:zakupki:procurements-detail", args=[record.id]) + ) + self.assertEqual(detail.status_code, status.HTTP_200_OK) + + def test_financial_reports_list_and_retrieve(self): + report = FinancialReport.objects.create( + external_id=_digits(5), + ogrn=_digits(13), + file_name=f"fin_{_digits(5)}_{_digits(13)}.xlsx", + file_hash=fake.sha256(raw_output=False), + load_batch=fake.random_int(min=1, max=1000), + status=FinancialReport.Status.SUCCESS, + source=FinancialReport.SourceType.API, + ) + FinancialReportLine.objects.create( + report=report, + form_code="1", + line_code=_digits(4), + line_name=fake.word(), + year=fake.random_int(min=2020, max=2025), + period_start=fake.random_int(min=1, max=999), + period_end=fake.random_int(min=1, max=999), + ) + self.client.force_authenticate(self.user) + url = reverse("api_v1:fns:fns-reports-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + detail = self.client.get( + reverse("api_v1:fns:fns-reports-detail", args=[report.id]) + ) + self.assertEqual(detail.status_code, status.HTTP_200_OK) + self.assertIn("lines", detail.data) + + def test_system_logs_and_proxies_admin_only(self): + log = ParserLoadLogFactory() + proxy = ProxyFactory() + url_logs = reverse("api_v1:system:parser-logs-list") + url_proxy = reverse("api_v1:system:proxies-list") + + response = self.client.get(url_logs) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + self.client.force_authenticate(self.user) + response = self.client.get(url_logs) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + self.client.force_authenticate(self.admin) + response = self.client.get(url_logs) + self.assertEqual(response.status_code, status.HTTP_200_OK) + detail = self.client.get( + reverse("api_v1:system:parser-logs-detail", args=[log.id]) + ) + self.assertEqual(detail.status_code, status.HTTP_200_OK) + + proxy_response = self.client.get(url_proxy) + self.assertEqual(proxy_response.status_code, status.HTTP_200_OK) + proxy_detail = self.client.get( + reverse("api_v1:system:proxies-detail", args=[proxy.id]) + ) + self.assertEqual(proxy_detail.status_code, status.HTTP_200_OK) + + def test_fns_upload_invalid_filename(self): + self.client.force_authenticate(self.user) + with tempfile.TemporaryDirectory() as tmpdir: + watch_dir = os.path.join(tmpdir, "watch") + processed_dir = os.path.join(tmpdir, "processed") + failed_dir = os.path.join(tmpdir, "failed") + content = _build_fns_excel_bytes() + upload = SimpleUploadedFile( + f"bad_{fake.random_int()}.xlsx", + content, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + with self.settings( + FNS_WATCH_DIRECTORY=watch_dir, + FNS_PROCESSED_DIRECTORY=processed_dir, + FNS_FAILED_DIRECTORY=failed_dir, + ): + url = reverse("api_v1:fns:fns-upload") + response = self.client.post(url, {"files": [upload]}, format="multipart") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/tests/apps/user/test_admin.py b/tests/apps/user/test_admin.py new file mode 100644 index 0000000..76d8414 --- /dev/null +++ b/tests/apps/user/test_admin.py @@ -0,0 +1,85 @@ +"""Tests for user admin configuration.""" + +from __future__ import annotations + +from django.contrib.admin.sites import AdminSite +from django.contrib.messages.storage.fallback import FallbackStorage +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import RequestFactory, TestCase + +from apps.user.admin import ProfileAdmin, UserAdmin +from apps.user.models import Profile, User +from tests.utils.fixtures import fake + + +class UserAdminTest(TestCase): + def setUp(self): + self.site = AdminSite() + self.admin = UserAdmin(User, self.site) + self.factory = RequestFactory() + + def _request(self): + request = self.factory.get("/") + request.user = User.objects.create_superuser( + email=fake.email(), + username=fake.user_name(), + password="pass", + ) + request.session = {} + request._messages = FallbackStorage(request) + return request + + def test_badges(self): + verified = User.objects.create_user( + email=fake.email(), username=fake.user_name(), password="pass", is_verified=True + ) + unverified = User.objects.create_user( + email=fake.email(), username=fake.user_name(), password="pass", is_verified=False + ) + self.assertIn("span", str(self.admin.is_verified_badge(verified))) + self.assertIn("span", str(self.admin.is_verified_badge(unverified))) + + verified.is_active = True + unverified.is_active = False + self.assertIn("span", str(self.admin.is_active_badge(verified))) + self.assertIn("span", str(self.admin.is_active_badge(unverified))) + + def test_actions(self): + users = [ + User.objects.create_user( + email=fake.email(), username=fake.user_name(), password="pass" + ) + for _ in range(2) + ] + request = self._request() + qs = User.objects.filter(id__in=[u.id for u in users]) + + self.admin.verify_users(request, qs) + self.assertTrue(User.objects.filter(is_verified=True).count() >= 2) + + self.admin.unverify_users(request, qs) + self.assertEqual(User.objects.filter(is_verified=True).count(), 0) + + self.admin.deactivate_users(request, qs) + self.assertEqual(User.objects.filter(is_active=True).count(), 1) # admin user + + self.admin.activate_users(request, qs) + self.assertTrue(User.objects.filter(is_active=True).count() >= 3) + + +class ProfileAdminTest(TestCase): + def setUp(self): + self.site = AdminSite() + self.admin = ProfileAdmin(Profile, self.site) + + def test_has_avatar_badge(self): + user = User.objects.create_user( + email=fake.email(), username=fake.user_name(), password="pass" + ) + profile = user.profile + self.assertIn("span", str(self.admin.has_avatar(profile))) + + avatar = SimpleUploadedFile("avatar.png", b"img", content_type="image/png") + profile.avatar = avatar + profile.save(update_fields=["avatar"]) + self.assertIn("span", str(self.admin.has_avatar(profile))) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1ba9682 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Pytest configuration to ensure src/ is importable.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" + +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..81d9f79 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,5 @@ +"""Test utilities.""" + +from .http_server import TestHTTPServer, Response + +__all__ = ["TestHTTPServer", "Response"] diff --git a/tests/utils/fixtures.py b/tests/utils/fixtures.py new file mode 100644 index 0000000..7d3c925 --- /dev/null +++ b/tests/utils/fixtures.py @@ -0,0 +1,241 @@ +"""Fixture builders for integration tests (Faker-based).""" + +from __future__ import annotations + +import io +import zipfile +from dataclasses import dataclass +from typing import Iterable + +from faker import Faker +from openpyxl import Workbook + +fake = Faker("ru_RU") + + +@dataclass(frozen=True) +class CertificateRow: + issue_date: str + certificate_number: str + expiry_date: str + certificate_file_url: str + organisation_name: str + inn: str + ogrn: str + + +@dataclass(frozen=True) +class ManufacturerRow: + full_legal_name: str + inn: str + ogrn: str + address: str + + +@dataclass(frozen=True) +class InspectionRow: + registration_number: str + inn: str + ogrn: str + organisation_name: str + control_authority: str + inspection_type: str + inspection_form: str + start_date: str + end_date: str + status: str + legal_basis: str + result: str + + +@dataclass(frozen=True) +class ProcurementRow: + purchase_number: str + purchase_name: str + customer_inn: str + customer_kpp: str + customer_ogrn: str + customer_name: str + max_price: str + publish_date: str + end_date: str + status: str + href: str + + +def _digits(length: int) -> str: + return "".join(str(fake.random_int(0, 9)) for _ in range(length)) + + +def build_minpromtorg_certificates_excel(count: int = 5) -> tuple[bytes, list[CertificateRow]]: + wb = Workbook() + ws = wb.active + ws.append( + [ + "issue_date", + "certificate_number", + "expiry_date", + "certificate_file_url", + "organisation_name", + "inn", + "ogrn", + ] + ) + + rows: list[CertificateRow] = [] + for _ in range(count): + row = CertificateRow( + issue_date=str(fake.date()), + certificate_number=f"{fake.bothify(text='??-####-#####')}", + expiry_date=str(fake.date()), + certificate_file_url=fake.url(), + organisation_name=fake.company(), + inn=_digits(10), + ogrn=_digits(13), + ) + rows.append(row) + ws.append( + [ + row.issue_date, + row.certificate_number, + row.expiry_date, + row.certificate_file_url, + row.organisation_name, + row.inn, + row.ogrn, + ] + ) + + buf = io.BytesIO() + wb.save(buf) + wb.close() + return buf.getvalue(), rows + + +def build_minpromtorg_manufacturers_excel( + count: int = 5, +) -> tuple[bytes, list[ManufacturerRow]]: + wb = Workbook() + ws = wb.active + ws.append(["full_legal_name", "inn", "ogrn", "address"]) + + rows: list[ManufacturerRow] = [] + for _ in range(count): + row = ManufacturerRow( + full_legal_name=fake.company(), + inn=_digits(10), + ogrn=_digits(13), + address=fake.address().replace("\n", ", "), + ) + rows.append(row) + ws.append([row.full_legal_name, row.inn, row.ogrn, row.address]) + + buf = io.BytesIO() + wb.save(buf) + wb.close() + return buf.getvalue(), rows + + +def build_proverki_xml(count: int = 3) -> tuple[bytes, list[InspectionRow]]: + rows: list[InspectionRow] = [] + parts = ["", ""] + + for _ in range(count): + row = InspectionRow( + registration_number=_digits(12), + inn=_digits(10), + ogrn=_digits(13), + organisation_name=fake.company(), + control_authority=fake.company(), + inspection_type=fake.word(), + inspection_form=fake.word(), + start_date=str(fake.date()), + end_date=str(fake.date()), + status=fake.word(), + legal_basis=fake.sentence(nb_words=4), + result=fake.sentence(nb_words=3), + ) + rows.append(row) + parts.append( + "" + ) + + parts.append("") + xml = "".join(parts).encode("utf-8") + return xml, rows + + +def build_zakupki_xml(count: int = 3) -> tuple[bytes, list[ProcurementRow]]: + rows: list[ProcurementRow] = [] + parts = ["", ""] + + for _ in range(count): + row = ProcurementRow( + purchase_number=_digits(19), + purchase_name=fake.sentence(nb_words=6), + customer_inn=_digits(10), + customer_kpp=_digits(9), + customer_ogrn=_digits(13), + customer_name=fake.company(), + max_price=str(fake.pydecimal(left_digits=7, right_digits=2, positive=True)), + publish_date=str(fake.date()), + end_date=str(fake.date()), + status=fake.word(), + href=fake.url(), + ) + rows.append(row) + parts.append( + "" + f"{row.purchase_number}" + f"{row.purchase_name}" + "" + f"{row.customer_inn}" + f"{row.customer_kpp}" + f"{row.customer_ogrn}" + f"{row.customer_name}" + "" + f"{row.max_price}" + f"{row.publish_date}" + f"{row.end_date}" + f"{row.status}" + f"{row.href}" + "" + ) + + parts.append("") + xml = "".join(parts).encode("utf-8") + return xml, rows + + +def build_zip(files: Iterable[tuple[str, bytes]]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for name, content in files: + zf.writestr(name, content) + return buf.getvalue() + + +__all__ = [ + "fake", + "CertificateRow", + "ManufacturerRow", + "InspectionRow", + "ProcurementRow", + "build_minpromtorg_certificates_excel", + "build_minpromtorg_manufacturers_excel", + "build_proverki_xml", + "build_zakupki_xml", + "build_zip", +] diff --git a/tests/utils/http_server.py b/tests/utils/http_server.py new file mode 100644 index 0000000..ba41655 --- /dev/null +++ b/tests/utils/http_server.py @@ -0,0 +1,134 @@ +"""Lightweight in-memory HTTP router for integration tests (no sockets).""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Callable +from urllib.parse import urlparse + +import requests +from requests.adapters import BaseAdapter +from requests.models import Response as RequestsResponse + + +@dataclass +class Response: + status: int = 200 + body: bytes = b"" + headers: dict[str, str] = field(default_factory=dict) + + +RouteHandler = Callable[[SimpleNamespace, bytes], Response] + + +def _json_response(data: object, status: int = 200) -> Response: + body = json.dumps(data, ensure_ascii=False).encode("utf-8") + return Response( + status=status, + body=body, + headers={"Content-Type": "application/json; charset=utf-8"}, + ) + + +class _InMemoryAdapter(BaseAdapter): + def __init__(self, routes: dict[tuple[str, str], Response | RouteHandler]) -> None: + super().__init__() + self._routes = routes + + def send(self, request, **_kwargs): # noqa: D401 + parsed = urlparse(request.url) + key = (request.method.upper(), parsed.path) + route = self._routes.get(key) + + if route is None: + response = Response(status=404, body=b"", headers={}) + else: + body = request.body or b"" + if isinstance(body, str): + body = body.encode("utf-8") + if callable(route): + response = route( + SimpleNamespace( + path=parsed.path, + query=parsed.query, + method=request.method.upper(), + ), + body, + ) + else: + response = route + + return self._build_response(request, response) + + def _build_response(self, request, response: Response) -> RequestsResponse: + resp = RequestsResponse() + resp.status_code = response.status + resp._content = response.body + resp.headers.update(response.headers) + resp.url = request.url + resp.request = request + return resp + + def close(self) -> None: # noqa: D401 + return + + +class TestHTTPServer: + """Context-managed in-memory HTTP router with requests adapter.""" + + def __init__(self) -> None: + self._routes: dict[tuple[str, str], Response | RouteHandler] = {} + self._adapter = _InMemoryAdapter(self._routes) + self._base_url = "http://testserver" + self._started = False + + @property + def base_url(self) -> str: + return self._base_url + + @property + def adapter(self) -> BaseAdapter: + return self._adapter + + def mount(self, client_or_session) -> None: + session = getattr(client_or_session, "session", client_or_session) + session.mount(self._base_url, self._adapter) + session.mount(self._base_url.replace("http://", "https://", 1), self._adapter) + + def add_json(self, path: str, data: object, *, status: int = 200) -> None: + self._routes[("GET", path)] = _json_response(data, status=status) + + def add_bytes( + self, + path: str, + data: bytes, + *, + content_type: str = "application/octet-stream", + status: int = 200, + ) -> None: + self._routes[("GET", path)] = Response( + status=status, + body=data, + headers={"Content-Type": content_type}, + ) + + def add_route(self, method: str, path: str, handler: RouteHandler) -> None: + self._routes[(method.upper(), path)] = handler + + def start(self) -> None: + self._started = True + + def stop(self) -> None: + self._started = False + + def __enter__(self) -> "TestHTTPServer": + self.start() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.stop() + + +__all__ = ["TestHTTPServer", "Response"] diff --git a/СОГЛАШЕНИЕ_Qwen.md b/СОГЛАШЕНИЕ_Qwen.md new file mode 100644 index 0000000..efc7a83 --- /dev/null +++ b/СОГЛАШЕНИЕ_Qwen.md @@ -0,0 +1,721 @@ +# СОГЛАШЕНИЕ + +**о порядке осуществления родительских прав, месте жительства несовершеннолетнего ребёнка, порядке общения с ребёнком, участии родителей в воспитании и развитии ребёнка, порядке выезда ребёнка за пределы Российской Федерации, уплате алиментов и порядке пользования общим имуществом** + +--- + +город Воронеж, Воронежская область, Российская Федерация + +«\_\_\_» \_\_\_\_\_\_\_\_\_\_\_\_\_\_ двадцать \_\_\_\_\_\_ года + +--- + +## ПРЕАМБУЛА + +Мы, нижеподписавшиеся Стороны настоящего Соглашения, являясь родителями несовершеннолетнего ребёнка — **Кривова Святослава Дмитриевича**, 01 марта 2018 года рождения (свидетельство о рождении серия II-СИ № 834107, выдано \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_), + +**ПРИНИМАЯ ВО ВНИМАНИЕ**, что: + +— Конституция Российской Федерации гарантирует защиту семьи, материнства, отцовства и детства (статья 38); + +— забота о детях, их воспитание являются равным правом и обязанностью обоих родителей (часть 2 статьи 38 Конституции Российской Федерации); + +— каждый ребёнок имеет право жить и воспитываться в семье, право знать своих родителей, право на их заботу, совместное с ними проживание (статья 54 Семейного кодекса Российской Федерации); + +— ребёнок имеет право на общение с обоими родителями, бабушкой, дедушкой, братьями, сёстрами и другими родственниками (статья 55 Семейного кодекса Российской Федерации); + +— родительские права не могут осуществляться в противоречии с интересами детей, обеспечение интересов детей должно быть предметом основной заботы их родителей (пункт 1 статьи 65 Семейного кодекса Российской Федерации); + +— все вопросы, касающиеся воспитания и образования детей, решаются родителями по их взаимному согласию исходя из интересов детей и с учётом мнения детей (пункт 2 статьи 65 Семейного кодекса Российской Федерации); + +— родители вправе заключить соглашение о порядке осуществления родительских прав родителем, проживающим отдельно от ребёнка (пункт 2 статьи 66 Семейного кодекса Российской Федерации); + +— родители обязаны содержать своих несовершеннолетних детей и вправе определить порядок и форму предоставления содержания путём заключения соглашения (статьи 80, 99–100 Семейного кодекса Российской Федерации); + +**ПОДТВЕРЖДАЯ**, что настоящее Соглашение заключается нами добровольно, без какого-либо принуждения, угроз, насилия или введения в заблуждение, при полном понимании существа заключаемой сделки и её правовых последствий; + +**РУКОВОДСТВУЯСЬ** исключительно интересами несовершеннолетнего ребёнка и стремлением обеспечить его гармоничное развитие, сохранить устойчивую эмоциональную связь ребёнка с каждым из родителей, предотвратить возможные конфликты и споры в будущем; + +**ЗАКЛЮЧИЛИ** настоящее Соглашение о нижеследующем: + +--- + +## ОПРЕДЕЛЕНИЯ И ТОЛКОВАНИЕ ТЕРМИНОВ + +В целях настоящего Соглашения нижеследующие термины имеют следующее значение: + +**«Уважительные причины»** — обстоятельства, объективно препятствующие исполнению обязательств по настоящему Соглашению, подтверждённые документально, включая, но не ограничиваясь: +— заболевание Ребёнка, подтверждённое медицинским заключением (справкой врача, выпиской из медицинской карты, листком нетрудоспособности по уходу за ребёнком); +— госпитализация Ребёнка или одного из родителей; +— карантин в образовательной организации или по месту жительства; +— участие Ребёнка в официальном мероприятии образовательной организации (олимпиада, соревнование, выездное мероприятие), подтверждённое документально. + +**«Систематическое нарушение»** — неисполнение или ненадлежащее исполнение обязательств по настоящему Соглашению, совершённое два и более раза в течение шести (последовательных) календарных месяцев, при отсутствии уважительных причин. + +**«Надлежащее уведомление»** — уведомление, направленное в порядке, установленном статьёй «Порядок уведомлений» настоящего Соглашения. + +**«Форс-мажор»** — чрезвычайные и непредотвратимые обстоятельства, находящиеся вне контроля Сторон, включая, но не ограничиваясь: стихийные бедствия, военные действия, введение режима чрезвычайной ситуации, карантинные ограничения федерального или регионального уровня, акты органов государственной власти, делающие исполнение обязательств невозможным. + +**«Разумное время суток»** — период с 08:00 до 21:00 по местному времени места нахождения Ребёнка, за исключением экстренных ситуаций. + +**«Экстренная ситуация»** — ситуация, создающая непосредственную угрозу жизни или здоровью Ребёнка и требующая незамедлительных действий. + +--- + +## СТАТЬЯ 1. СТОРОНЫ СОГЛАШЕНИЯ + +1.1. Сторонами настоящего Соглашения являются: + +**ОТЕЦ:** + +Гражданин Российской Федерации **КРИВОВ ДМИТРИЙ ЕВГЕНЬЕВИЧ**, +дата рождения: 26 мая 1989 года, +место рождения: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_, +паспорт гражданина Российской Федерации: серия 20 09 № 084268, +выдан 09 июля 2009 года Отделением УФМС России по Воронежской области в Грибановском районе, +код подразделения: 360-017, +адрес регистрации по месту жительства: 396316, Воронежская область, Новоусманский район, село Новая Усмань, улица Серебряный Век, дом 2, квартира 62, +СНИЛС: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_, +ИНН: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_, +именуемый в дальнейшем «Отец», с одной стороны, + +**и** + +**МАТЬ:** + +Гражданка Российской Федерации **КРИВОВА ТАТЬЯНА НИКОЛАЕВНА**, +дата рождения: 20 февраля 1995 года, +место рождения: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_, +паспорт гражданина Российской Федерации: серия 20 17 № 119428, +выдан 20 сентября 2017 года Отделом УФМС России по Воронежской области в Левобережном районе города Воронежа, +код подразделения: 360-003, +адрес регистрации по месту жительства: 396316, Воронежская область, Новоусманский район, село Новая Усмань, улица Серебряный Век, дом 2, квартира 62, +СНИЛС: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_, +ИНН: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_, +именуемая в дальнейшем «Мать», с другой стороны, + +совместно именуемые в дальнейшем «Стороны», а по отдельности — «Сторона». + +1.2. Стороны являются родителями несовершеннолетнего ребёнка: + +**КРИВОВ СВЯТОСЛАВ ДМИТРИЕВИЧ**, +дата рождения: 01 марта 2018 года, +место рождения: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_, +свидетельство о рождении: серия II-СИ № 834107, +выдано: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ (орган ЗАГС), +дата выдачи: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_, +актовая запись № \_\_\_\_\_\_\_\_\_\_, +именуемый в дальнейшем «Ребёнок» или «несовершеннолетний». + +1.3. На момент заключения настоящего Соглашения брак между Сторонами \[расторгнут на основании решения \_\_\_\_\_\_\_\_\_\_ суда от «\_\_\_» \_\_\_\_\_\_\_\_\_\_ 20\_\_ года по делу № \_\_\_\_\_\_\_\_\_ / не заключался / не расторгнут\]. + +--- + +## СТАТЬЯ 2. ПРАВОВАЯ ОСНОВА СОГЛАШЕНИЯ + +2.1. Настоящее Соглашение заключено в соответствии с положениями Семейного кодекса Российской Федерации: + +— статья 54 СК РФ — право ребёнка жить и воспитываться в семье; +— статья 55 СК РФ — право ребёнка на общение с родителями и другими родственниками; +— статья 57 СК РФ — право ребёнка выражать своё мнение; +— статья 61 СК РФ — равенство прав и обязанностей родителей; +— статья 63 СК РФ — права и обязанности родителей по воспитанию и образованию детей; +— статья 65 СК РФ — осуществление родительских прав; +— статья 66 СК РФ — осуществление родительских прав родителем, проживающим отдельно от ребёнка; +— статья 67 СК РФ — право на общение с ребёнком бабушки, дедушки, братьев, сестёр и других родственников; +— статья 80 СК РФ — обязанность родителей содержать своих несовершеннолетних детей; +— статья 86 СК РФ — участие родителей в дополнительных расходах на детей; +— статьи 99–101 СК РФ — соглашение об уплате алиментов, его форма и порядок заключения. + +2.2. При толковании и применении настоящего Соглашения Стороны и суд руководствуются разъяснениями Верховного Суда Российской Федерации, **действующими на дату рассмотрения спора**, в том числе: + +— **Постановлением Пленума Верховного Суда Российской Федерации от 27 мая 1998 года № 10** «О применении судами законодательства при разрешении споров, связанных с воспитанием детей»; +— **Постановлением Пленума Верховного Суда Российской Федерации от 26 декабря 2017 года № 56** «О применении судами законодательства при рассмотрении дел о взыскании алиментов». + +2.3. Стороны подтверждают, что заключают настоящее Соглашение добровольно, без принуждения, исключительно в интересах Ребёнка и с целью предотвращения возможных споров в будущем. + +2.4. Стороны признают, что в соответствии со статьёй 57 Семейного кодекса Российской Федерации Ребёнок вправе выражать своё мнение при решении в семье любого вопроса, затрагивающего его интересы. По достижении Ребёнком десятилетнего (10) возраста учёт его мнения при разрешении споров является обязательным, за исключением случаев, когда это противоречит его интересам. + +2.5. Стороны обязуются периодически (не реже одного раза в год) пересматривать условия настоящего Соглашения с учётом возрастных потребностей Ребёнка, его мнения и изменившихся обстоятельств. + +--- + +3\. ОСНОВНЫЕ ПРИНЦИПЫ ВОСПИТАНИЯ РЕБЁНКА + +3.1. Стороны подтверждают, что ребёнок имеет право на: + +— любовь, заботу и внимание обоих родителей; +— участие обоих родителей в его жизни; +— сохранение устойчивой эмоциональной связи с каждым из родителей. + +3.2. Родители обязуются: + +— не формировать негативное отношение ребёнка к другому родителю; +— не обсуждать конфликты при ребёнке; +— не использовать ребёнка как средство давления. + +3.3. Ограничение общения допускается исключительно по решению суда. + +--- + +4\. МЕСТО ЖИТЕЛЬСТВА РЕБЁНКА + +4.1. Местом жительства несовершеннолетнего Кривова Святослава Дмитриевича определяется место проживания Матери по адресу: Воронежская область, Новоусманский район, село Новая Усмань, улица Серебряный Век, дом 2, квартира 62. + +4.2. Мать обязуется обеспечивать несовершеннолетнему условия, соответствующие требованиям статьи 65 Семейного кодекса Российской Федерации, включая: + +— надлежащие санитарно-гигиенические условия проживания; +— полноценное питание и обеспечение одеждой по сезону; +— своевременное медицинское обслуживание и прохождение профилактических осмотров; +— условия для получения образования и всестороннего развития. + +4.3. Изменение места жительства несовершеннолетнего допускается исключительно с письменного согласия Отца. В случае отказа в согласии спор разрешается судом с учётом положений статьи 65 Семейного кодекса Российской Федерации и иных норм СК РФ, применимых к спору. + +4.4. Мать обязуется уведомлять Отца о планируемом изменении места жительства не менее чем за тридцать (30) календарных дней до совершения переезда путём направления письменного уведомления с указанием: + +— нового адреса проживания; +— образовательного учреждения; +— лиц, проживающих совместно с ребёнком. + +4.5. Временное пребывание несовершеннолетнего за пределами постоянного места жительства сроком свыше трёх (3) суток подлежит предварительному уведомлению Отца с указанием маршрута, сроков и контактных данных сопровождающих лиц. + +--- + +5\. ПОРЯДОК ОБЩЕНИЯ С РЕБЁНКОМ ПРИ ПРОЖИВАНИИ РОДИТЕЛЕЙ В РАЗНЫХ ГОРОДАХ + +5.1. Стороны подтверждают, что могут проживать в разных городах. + +5.2. Отец вправе забирать ребёнка не реже одного раза в месяц на срок от 3 до 7 календарных дней подряд с правом ночёвок. + +5.3. В случае невозможности согласования дат первая неделя месяца считается закреплённой за Отцом. + +5.4. Все официальные длительные выходные (3 и более дней) проводятся с Отцом и Матерью поочерёдно. + +5.5. Мать не вправе отказывать в общении без уважительных оснований, которыми признаются исключительно: + +— состояние здоровья ребёнка, подтверждённое медицинским заключением; +— проведение плановой медицинской процедуры или госпитализация; +— участие ребёнка в официальном мероприятии образовательной организации (олимпиада, соревнование, выездное мероприятие), подтверждённое документально. + +5.6. Отказ в общении по иным основаниям расценивается как неисполнение настоящего Соглашения и влечёт право Отца на обращение в суд с требованием об устранении препятствий к общению в порядке статьи 66 Семейного кодекса Российской Федерации. + +5.7. **Срок уведомления.** Отец уведомляет Мать о планируемых датах общения не менее чем за четырнадцать (14) календарных дней до начала периода, с указанием дат, адреса пребывания Ребёнка и контактных данных. + +5.8. **Учебные дни.** Если период общения включает учебные дни, Отец обеспечивает посещение Ребёнком образовательной организации либо согласовывает с Матерью и школой временное отсутствие. При невозможности обеспечить посещение школы период общения переносится на каникулы или выходные. + +5.9. **Ответственность.** В период пребывания Ребёнка с Отцом ответственность за его безопасность, здоровье, питание и режим несёт Отец. При пребывании Ребёнка у родственников Отца ответственность сохраняется за Отцом. + +--- + +5-А. ПОРЯДОК ОБЩЕНИЯ ПРИ ПРОЖИВАНИИ РОДИТЕЛЕЙ В ОДНОМ ГОРОДЕ (ПЕРЕКЛЮЧЕНИЕ РЕЖИМА) + +5-А.1. В случае переезда Отца в город проживания Матери с Ребёнком (проживание в одном городе), порядок общения изменяется в соответствии с настоящей статьёй. + +5-А.2. **Критерий «одного города»:** + +— фактическое проживание Отца в том же населённом пункте, где проживает Мать с Ребёнком, или в пределах транспортной доступности не более 30 минут **в обычных дорожных условиях**; +— продолжительность проживания не менее тридцати (30) календарных дней подряд. + +5-А.3. **Подтверждение факта проживания:** + +— договор найма (аренды) жилья; +— регистрация по месту пребывания (временная регистрация); +— правоустанавливающие документы на жильё; +— иные доказательства, подтверждающие фактическое проживание. + +5-А.4. **График общения при проживании в одном городе:** + +**Будни (ночёвка):** один (1) раз в неделю с ночёвкой. +**По умолчанию:** со среды 17:00 (забирает из школы/у Матери) до четверга 08:30 (возврат в школу/Матери). +При невозможности (занятия/здоровье/мероприятие) — переносится на ближайший будний день с уведомлением не менее чем за 24 часа. + +**Будни (без ночёвки):** один (1) раз в неделю без ночёвки — с 17:00 до 20:00 (день недели по согласованию). + +**Выходные:** **1-е и 3-и выходные** каждого месяца — с пятницы 18:00 до воскресенья 20:00 с ночёвкой. + +**Каникулы:** порядок аналогичен статье 6 настоящего Соглашения. + +5-А.5. Отец уведомляет Мать о переезде и предоставляет подтверждающие документы. Новый график вступает в силу с первого числа месяца, следующего за месяцем подтверждения факта проживания. + +5-А.6. При выезде Отца из города проживания Ребёнка автоматически восстанавливается график, установленный статьёй 5 настоящего Соглашения. + +--- + +6\. КАНИКУЛЫ + +6.1. Ребёнок проводит с Отцом: + +— 50% зимних каникул; +— 50% весенних каникул; +— 50% осенних каникул; +— не менее 30 календарных дней подряд летом. + +6.2. Конкретные даты каникулярного времени согласовываются Сторонами не позднее чем за четырнадцать (14) дней до начала каникул. + +6.3. **График по умолчанию (при недостижении согласия):** + +**Зимние каникулы:** +— в чётные годы: первая половина каникул (с первого дня каникул до 31 декабря включительно) — с Отцом; +— в нечётные годы: вторая половина каникул (с 1 января до окончания каникул) — с Отцом. + +**Летние каникулы:** +— Отец выбирает один непрерывный период продолжительностью 30 календарных дней и один период 14 календарных дней; +— уведомление о датах направляется Матери не позднее 1 мая; +— при отсутствии уведомления по умолчанию: 1–30 июля и 1–14 августа. + +**Весенние и осенние каникулы:** +— первая половина (с первого дня) — с Отцом; +— вторая половина — с Матерью. + +6.4. Передача Ребёнка при смене периодов осуществляется в 12:00 по местному времени места нахождения Ребёнка, если иное не согласовано Сторонами. + +--- + +## 6-А. ПОРЯДОК ПЕРЕДАЧИ РЕБЁНКА + +6-А.1. Передача Ребёнка осуществляется в согласованном месте: + +— по умолчанию: у подъезда по адресу проживания Матери; +— либо в ином месте по согласованию Сторон (школа, нейтральная точка). + +6-А.2. Сторона, принимающая Ребёнка, обязуется прибыть в согласованное место вовремя. Допустимое опоздание — пятнадцать (15) минут. + +6-А.3. Опоздание более 15 минут фиксируется перепиской (скриншот мессенджера с указанием времени) и считается нарушением, если опоздавшая Сторона не подтвердит уважительные причины в соответствии с разделом «Определения». + +6-А.4. При опоздании более шестидесяти (60) минут встреча считается сорванной по вине опоздавшей Стороны. Вопрос компенсационного времени регулируется статьёй 19 настоящего Соглашения с учётом виновной Стороны. + +6-А.5. При передаче Ребёнка передающая Сторона кратко сообщает принимающей Стороне актуальную информацию: + +— состояние здоровья Ребёнка; +— приём лекарств (при наличии); +— особенности режима; +— наличие школьных заданий или мероприятий. + +6-А.6. Принимающая Сторона подтверждает получение информации и принятие Ребёнка подписью в журнале передачи (при его ведении) или ответным сообщением в мессенджере. + +--- + +7\. ДИСТАНЦИОННОЕ ОБЩЕНИЕ + +7.1. Отец вправе ежедневно общаться с Ребёнком по телефону и видеосвязи в разумное время суток, по инициативе Отца или Ребёнка. Рекомендуемая продолжительность — не менее десяти (10) и не более тридцати (30) минут; при желании Ребёнка продолжить разговор — по согласованию. + +7.2. Мать обязуется не препятствовать такому общению и обеспечивать техническую возможность связи. + +--- + +8\. ВЫЕЗД РЕБЁНКА ЗА ПРЕДЕЛЫ РОССИЙСКОЙ ФЕДЕРАЦИИ + +8.1. Отец вправе вывозить Ребёнка за пределы Российской Федерации на срок до тридцати (30) дней при направлении Матери письменного уведомления не менее чем за семь (7) дней до даты выезда с указанием: + +— страны (стран) посещения; +— точных дат выезда и возвращения; +— маршрута следования (при наличии); +— адреса проживания за рубежом (при наличии); +— контактных данных для связи за рубежом (telegram, местный номер телефона). + +8.2. Мать обязуется оформить нотариальное согласие на выезд Ребёнка за границу с Отцом в течение пяти (5) рабочих дней с момента получения уведомления. + +8.3. Если для оформления нотариального согласия требуются дополнительные сведения (по запросу нотариуса), Отец обязуется предоставить их в течение двух (2) рабочих дней с момента получения запроса. Срок оформления согласия приостанавливается до предоставления таких сведений. + +8.4. Непредоставление Отцом запрошенных сведений в указанный срок освобождает Мать от обязанности оформить согласие в срок, указанный в п.8.2. + +--- + +9\. ВАЖНЫЕ РЕШЕНИЯ О РЕБЁНКЕ + +9.1. Решения о: + +— смене школы; +— медицинском лечении (за исключением экстренных случаев); +— получении загранпаспорта; +— переезде за границу + +принимаются только по взаимному согласию родителей. + +9.2. В случае угрозы жизни или здоровью несовершеннолетнего любой из Сторон вправе принять незамедлительное решение о медицинском вмешательстве без согласования с другой Стороной с обязательным уведомлением в течение одного часа. + +9.3. **Секции и кружки.** Запись Ребёнка в спортивные секции, кружки, курсы и иные занятия, требующие регулярной оплаты или влияющие на график общения с Отцом, осуществляется по взаимному согласованию Сторон. Односторонняя запись Ребёнка на занятия, препятствующие времени общения с Отцом, не является уважительной причиной для отмены или сокращения такого общения. + +--- + +10\. СОВМЕСТНЫЕ СОБЫТИЯ И ДЕНЬ РОЖДЕНИЯ РЕБЁНКА + +10.1. День рождения Ребёнка является семейным праздником. + +10.2. Каждый родитель имеет право на личное поздравление Ребёнка с днём рождения продолжительностью не менее двух (2) часов, независимо от того, с кем Ребёнок находится в этот день. + +10.3. Родитель, организующий праздничное мероприятие (день рождения, утренник, выпускной, соревнование, концерт), обязан уведомить другого родителя о дате, времени и месте проведения не менее чем за семь (7) дней. + +10.4. Оба родителя имеют право присутствовать на любом мероприятии с участием Ребёнка **без предварительного согласования** с другим родителем, в том числе на: + +— утренниках и школьных праздниках; +— выпускных мероприятиях; +— спортивных соревнованиях; +— концертах и выступлениях; +— медицинских мероприятиях. + +10.5. Ни один из родителей не вправе препятствовать присутствию другого. + +10.6. **Правила поведения родителей на совместных мероприятиях:** + +— не обсуждать конфликты и спорные вопросы в присутствии Ребёнка и третьих лиц; +— соблюдать взаимное уважение и корректность; +— не прерывать общение Ребёнка с другим родителем; +— фокусироваться на интересах Ребёнка. + +10.7. Если празднование дня рождения перенесено на другую дату, права родителей на присутствие сохраняются в отношении перенесённой даты. + +--- + +11\. АЛИМЕНТНЫЕ ОБЯЗАТЕЛЬСТВА + +11.1. В соответствии со статьями 80, 99 и 100 Семейного кодекса Российской Федерации Отец принимает на себя обязательство по уплате алиментов на содержание несовершеннолетнего Кривова Святослава Дмитриевича в следующем размере: + +— базовые алименты на содержание ребёнка: двадцать тысяч (20 000) рублей ежемесячно; +— целевые алименты на оплату обучения в частной образовательной организации: тридцать пять тысяч (35 000) рублей ежемесячно. + +11.2. Алименты подлежат перечислению не позднее пятнадцатого (15) числа каждого календарного месяца на банковский счёт Матери. + +11.3. Реквизиты для перечисления алиментов: + +Получатель: Кривова Татьяна Николаевна +ИНН получателя: \[указать ИНН Матери\] +Номер банковского счёта: \[указать 20-значный номер счёта\] +БИК банка: \[указать 9-значный БИК банка\] +Наименование банка: \[полное наименование кредитной организации\] +Корреспондентский счёт банка: \[указать 20-значный номер корреспондентского счёта\] + +11.4. Мать обязуется уведомить Отца об изменении банковских реквизитов не позднее чем за пять (5) рабочих дней до даты очередного платежа путём направления письменного уведомления. Перечисление средств на прежние реквизиты после надлежащего уведомления об их изменении не признаётся исполнением алиментных обязательств. + +11.5. Обязательство по перечислению целевых алиментов на обучение в размере тридцати пяти тысяч (35 000) рублей прекращается автоматически с первого числа месяца, следующего за месяцем перевода ребёнка в государственную образовательную организацию с бесплатной формой обучения. Мать обязуется уведомить Отца о таком переводе в течение трёх (3) рабочих дней. + +11.6. Перевод ребёнка в образовательную организацию с иной стоимостью обучения, а равно увеличение стоимости обучения в текущей образовательной организации допускается исключительно с предварительного письменного согласия Отца, оформленного в виде дополнительного соглашения к настоящему документу, подлежащего нотариальному удостоверению. + +11.7. Дополнительные расходы на содержание, лечение, развитие и образование ребёнка, превышающие установленные настоящим Соглашением суммы, подлежат возмещению Отцом в размере пятидесяти процентов (50%) при одновременном соблюдении следующих условий: + +— предварительное согласование необходимости и размера расходов с Отцом; +— предоставление подлинников документов, подтверждающих факт и размер понесённых расходов (чеки, квитанции, договоры, акты выполненных работ). + +11.8. Базовая сумма алиментов на содержание ребёнка (20 000 рублей) подлежит ежегодной индексации с первого января каждого календарного года пропорционально росту величины прожиточного минимума для детей, установленной в Воронежской области. Целевая часть алиментов на обучение (35 000 рублей) индексации не подлежит. + +11.9. В случае просрочки уплаты алиментов свыше десяти (10) календарных дней Отец уплачивает Матери неустойку в размере одной десятой процента (0,1%) от суммы невыплаченных алиментов за каждый день просрочки в соответствии со статьёй 115 Семейного кодекса Российской Федерации. + +11.10. Настоящий раздел Соглашения, удостоверенный нотариусом, имеет силу исполнительного листа в соответствии со статьёй 100 Семейного кодекса Российской Федерации и может быть предъявлен к принудительному исполнению в порядке, установленном Федеральным законом от 02.10.2007 № 229-ФЗ «Об исполнительном производстве». + +11.11. **Подарки и добровольные предоставления.** Подарки Ребёнку, оплата развлечений, поездок, отдыха, проведения праздников и иных добровольных расходов **не являются частью алиментов** и не могут зачитываться в счёт исполнения алиментных обязательств. Такие расходы осуществляются исключительно за счёт средств Стороны, которая их производит, по собственной инициативе и волеизъявлению. + +11.12. **Накопительные и инвестиционные программы.** Стороны вправе по взаимному согласию открыть на имя Ребёнка накопительный счёт, инвестиционный счёт, договор накопительного страхования жизни или иную программу формирования капитала в интересах Ребёнка. Условия участия каждой из Сторон в такой программе (размер взносов, периодичность, условия доступа к средствам) определяются отдельным письменным соглашением Сторон. Взносы в накопительные и инвестиционные программы не засчитываются в счёт алиментов, если иное прямо не предусмотрено таким соглашением. + +--- + +12\. ПРАВА РОДСТВЕННИКОВ НА ОБЩЕНИЕ С РЕБЁНКОМ + +12.1. Родители Отца — Кривов Евгений Михайлович и Кривова Елена Валентиновна (бабушка и дедушка несовершеннолетнего по отцовской линии) — имеют право на общение с несовершеннолетним Кривовым Святославом Дмитриевичем в соответствии со статьёй 67 Семейного кодекса Российской Федерации. + +12.2. Мать обязуется не препятствовать общению несовершеннолетнего ребёнка с бабушкой и дедушкой по отцовской линии и содействовать организации такого общения. + +12.3. Отец несёт ответственность за безопасность и благополучие ребёнка в период его пребывания с бабушкой и дедушкой. + +12.4. Воспрепятствование общению ребёнка с бабушкой и дедушкой без законных оснований признаётся нарушением настоящего Соглашения и влечёт право Отца на обращение в суд в порядке статьи 67 Семейного кодекса Российской Федерации. + +--- + +13\. ПОРЯДОК ПОЛЬЗОВАНИЯ ЖИЛЫМ ПОМЕЩЕНИЕМ + +13.1. Квартира, расположенная по адресу: Воронежская область, Новоусманский район, село Новая Усмань, улица Серебряный Век, дом 2, квартира 62 (далее — «Квартира»), принадлежит Сторонам на праве общей долевой собственности по одной второй (1/2) доле каждому на основании \[УКАЗАТЬ РЕКВИЗИТЫ ПРАВОУСТАНАВЛИВАЮЩЕГО ДОКУМЕНТА, ВЫПИСКИ ИЗ ЕГРН\]. + +13.2. Отец сохраняет право владения, пользования и распоряжения принадлежащей ему долей Квартиры в соответствии со статьями 244–252 Гражданского кодекса Российской Федерации, включая право проживания в Квартире без согласования с Матерью и без привязки к графику общения с несовершеннолетним. + +13.3. При намерении проживать в Квартире Отец обязуется уведомить Мать не менее чем за двадцать четыре (24) часа до планируемого заселения. + +13.4. Мать обязуется обеспечивать Отцу беспрепятственный доступ в Квартиру, в том числе: + +— незамедлительно уведомлять Отца об изменении замков входной двери, кодов домофона или иных средств ограничения доступа; +— передавать Отцу полный комплект ключей от новых замков в течение двадцати четырёх (24) часов с момента их установки; +— не блокировать доступ Отца к местам общего пользования и инженерным коммуникациям. + +13.5. Положения пункта 13.4 настоящего Соглашения не применяются в случае непосредственной угрозы жизни или здоровью Матери или несовершеннолетнего, подтверждённой обращением в правоохранительные органы. В таком случае замена замков производится незамедлительно с обязательным уведомлением Отца в течение двадцати четырёх (24) часов и предоставлением копии соответствующего заявления в правоохранительные органы. + +13.6. Ипотечные обязательства по кредитному договору № \[УКАЗАТЬ НОМЕР ДОГОВОРА\] от «\_\_\_» \_\_\_\_\_\_\_\_\_\_ 20\_\_ года, заключённому с \[УКАЗАТЬ ПОЛНОЕ НАИМЕНОВАНИЕ БАНКА\], исполняются Отцом единолично и в полном объёме. Мать, являясь созаёмщиком по указанному кредитному договору, освобождается от фактического участия в погашении ипотечного кредита во взаимоотношениях между Сторонами настоящего Соглашения. + +13.7. Коммунальные платежи, взносы на капитальный ремонт, расходы на содержание общего имущества многоквартирного дома и текущий ремонт Квартиры несёт Мать. Отец освобождается от участия в указанных расходах, за исключением периодов его постоянного проживания в Квартире продолжительностью свыше тридцати (30) календарных дней подряд. + +13.8. Отчуждение долей в праве собственности на Квартиру (продажа, дарение, мена, внесение в уставный капитал) допускается исключительно с соблюдением права преимущественной покупки другой Стороны в соответствии со статьёй 250 Гражданского кодекса Российской Федерации. + +--- + +14\. ОТВЕТСТВЕННОСТЬ СТОРОН + +14.1. За неисполнение или ненадлежащее исполнение обязательств по настоящему Соглашению Стороны несут ответственность в соответствии с действующим законодательством Российской Федерации. + +14.2. Настоящее Соглашение, удостоверенное нотариусом, в части алиментных обязательств имеет силу исполнительного листа в соответствии со статьёй 100 Семейного кодекса Российской Федерации. + +14.3. В случае систематического (два и более раза) нарушения одной из Сторон условий настоящего Соглашения о порядке общения с ребёнком другая Сторона вправе обратиться в суд с требованием об определении порядка общения с ребёнком и взыскании судебных расходов с виновной Стороны. + +14.4. Сторона, виновная в причинении убытков другой Стороне вследствие неисполнения или ненадлежащего исполнения обязательств по настоящему Соглашению, обязана возместить такие убытки в полном объёме, включая реальный ущерб и упущенную выгоду. + +--- + +## 14-А. ЗАПРЕТ ОДНОСТОРОННЕГО ИЗМЕНЕНИЯ СОГЛАШЕНИЯ + +14-А.1. Ни одна из Сторон не вправе в одностороннем порядке: + +— отменять согласованные встречи Ребёнка с другим родителем; +— изменять установленный график общения; +— ограничивать дистанционную связь Ребёнка с другим родителем; +— вводить новые условия передачи Ребёнка, не предусмотренные настоящим Соглашением. + +14-А.2. Изменения и дополнения к настоящему Соглашению действительны только в письменной форме, подписанной обеими Сторонами. + +14-А.3. Изменения, затрагивающие алиментные обязательства и иные существенные условия (место жительства Ребёнка, порядок выезда за рубеж), подлежат нотариальному удостоверению. + +14-А.4. При отсутствии согласия Сторон на изменение условий спор разрешается судом в порядке, установленном статьёй 15 настоящего Соглашения. + +--- + +15\. ПОРЯДОК РАЗРЕШЕНИЯ СПОРОВ + +15.1. Все споры и разногласия, возникающие между Сторонами в связи с исполнением настоящего Соглашения, разрешаются путём переговоров. + +15.2. В случае невозможности разрешения спора путём переговоров в течение тридцати (30) календарных дней с момента получения одной Стороной письменной претензии от другой Стороны, Стороны обязуются предпринять попытку урегулирования спора с привлечением профессионального медиатора в соответствии с Федеральным законом от 27.07.2010 № 193-ФЗ «Об альтернативной процедуре урегулирования споров с участием посредника (процедуре медиации)». + +15.3. При недостижении согласия в ходе медиации спор передаётся на рассмотрение в суд общей юрисдикции по месту жительства ответчика в соответствии с правилами подсудности, установленными Гражданским процессуальным кодексом Российской Федерации. + +15.4. Расходы на проведение процедуры медиации Стороны несут в равных долях, если иное не будет согласовано Сторонами дополнительно. + +--- + +## СТАТЬЯ 16. ПОРЯДОК УВЕДОМЛЕНИЙ + +16.1. Уведомления направляются следующими способами: + +**а) мессенджер (приоритет):** +Отец: Telegram/WhatsApp: +7 (\_\_\_) \_\_\_-\_\_-\_\_ +Мать: Telegram/WhatsApp: +7 (\_\_\_) \_\_\_-\_\_-\_\_ + +**б) e-mail:** +Отец: \_\_\_\_\_\_\_\_@\_\_\_\_\_\_ +Мать: \_\_\_\_\_\_\_\_@\_\_\_\_\_\_ + +**в) заказное письмо** по адресу регистрации. + +16.2. Уведомление считается полученным: через мессенджер — в момент статуса «доставлено»; e-mail — через 24 часа; заказное письмо — через 7 дней или в дату вручения. + +16.3. Каждая Сторона обязана уведомлять об изменении контактов. Неисполнение лишает права ссылаться на неполучение уведомления. + +--- + +## СТАТЬЯ 17. ФОРС-МАЖОР + +17.1. Стороны освобождаются от ответственности при обстоятельствах непреодолимой силы (форс-мажор), определённых в разделе «Определения». + +17.2. Сторона, ссылающаяся на форс-мажор, обязана: +— уведомить другую Сторону в течение 24 часов; +— предложить компенсационное время общения. + +17.3. **Не являются форс-мажором:** личные планы; загруженность на работе (кроме командировок); усталость/нежелание Ребёнка без заключения психолога; погода (кроме ЧС); отсутствие транспорта. + +--- + +## СТАТЬЯ 18. ГАРАНТИИ СТОРОН + +**18.1. Отец гарантирует:** +— своевременно исполнять алиментные обязательства; +— обеспечивать безопасность Ребёнка в период пребывания с ним; +— информировать Мать о состоянии Ребёнка и значимых событиях; +— не препятствовать связи Ребёнка с Матерью; +— возвращать Ребёнка в согласованное время; +— незамедлительно сообщать о происшествиях, травмах, заболеваниях. + +**18.2. Мать гарантирует:** +— не препятствовать общению Отца с Ребёнком; +— обеспечивать безопасность Ребёнка; +— информировать Отца о здоровье, успеваемости и событиях в жизни Ребёнка; +— не препятствовать связи Ребёнка с Отцом; +— передавать Ребёнка в согласованное время; +— незамедлительно сообщать о происшествиях; +— целевым образом расходовать алименты на нужды Ребёнка. + +**18.3. Обе Стороны гарантируют:** +— не формировать негатива к другому родителю; +— не обсуждать конфликты при Ребёнке; +— не использовать Ребёнка как средство давления; +— содействовать связи Ребёнка с обоими родителями. + +--- + +## СТАТЬЯ 18-А. ДОКУМЕНТЫ РЕБЁНКА + +18-А.1. Оригиналы документов Ребёнка (свидетельство о рождении, паспорт, СНИЛС, полис ОМС, медицинская карта) хранятся у Матери по месту жительства Ребёнка. + +18-А.2. Отец имеет право получить копии (сканы) любых документов Ребёнка по запросу. Мать обязуется направить запрошенные копии в течение одних (1) суток с момента получения запроса. + +18-А.3. При выезде Ребёнка с Отцом (поездка, отпуск, выезд за рубеж) Мать передаёт Отцу необходимые документы (паспорт, полис ОМС, согласие на выезд) в момент передачи Ребёнка. Передача документов фиксируется сообщением в мессенджере или записью в журнале передачи. + +18-А.4. Отец обязуется вернуть документы вместе с Ребёнком в момент возврата. + +--- + +## СТАТЬЯ 18-Б. ФОТО- И ВИДЕОСЪЁМКА + +18-Б.1. Оба родителя вправе фотографировать и снимать на видео Ребёнка во время общения с ним без ограничений. + +18-Б.2. Публикация фото/видео Ребёнка в открытом доступе (социальные сети, ютуб, блоги) осуществляется по взаимному согласию родителей. + +18-Б.3. Запрещается публикация материалов, дискредитирующих другого родителя или способных нанести вред интересам Ребёнка. + +--- + +## СТАТЬЯ 18-В. КОММУНИКАЦИЯ РОДИТЕЛЕЙ + +18-В.1. По вопросам, связанным с Ребёнком, Стороны общаются корректно и уважительно, преимущественно в письменной форме (мессенджер, электронная почта). + +18-В.2. Стороны воздерживаются от оскорблений, угроз, манипуляций и использования Ребёнка как посредника для передачи информации. + +18-В.3. Письменная коммуникация признаётся предпочтительной для фиксации договорённостей и предотвращения недоразумений. + +--- + +## СТАТЬЯ 19. КОМПЕНСАЦИОННОЕ ВРЕМЯ ОБЩЕНИЯ + +19.1. Если общение Отца с Ребёнком не состоялось по вине Матери (отказ в передаче Ребёнка, уклонение от согласования дат, иные препятствия без уважительных причин), Мать обязана предоставить компенсационное время общения в течение четырнадцати (14) календарных дней с момента пропущенного дня общения. + +19.2. Компенсационное время должно быть равным пропущенному или превышать его. Конкретные даты согласовываются Сторонами в течение семи (7) дней с момента пропуска. + +19.3. Если общение не состоялось по вине Отца (неявка без уважительных причин, отказ от заблаговременно согласованной встречи), пропущенное время не компенсируется. + +19.4. При невозможности согласовать компенсационное время в срок, указанный в п.19.2, компенсация предоставляется в ближайший подходящий период по базовому графику (выходные/каникулы) с уведомлением Матери не менее чем за семь (7) дней. + +--- + +## СТАТЬЯ 20. ФИКСАЦИЯ НАРУШЕНИЙ + +20.1. В целях документирования фактов нарушения настоящего Соглашения Стороны признают допустимыми доказательствами: + +— скриншоты переписки в мессенджерах и электронной почте с отображением даты и времени; +— аудиозаписи телефонных разговоров, в которых Сторона является участником; +— видеозаписи момента передачи/непередачи Ребёнка; +— письменные показания свидетелей (родственников, соседей, иных лиц); +— данные геолокации (история местоположения), подтверждающие прибытие в согласованное место. + +20.2. Стороны рекомендуют вести журнал передачи Ребёнка с фиксацией даты, времени, места и подписей обеих Сторон. + +20.3. Отказ Стороны от подписания журнала передачи фиксируется другой Стороной односторонне с указанием «от подписи отказал(ась)». + +20.4. Систематические нарушения, зафиксированные в соответствии с настоящей статьёй, являются основанием для обращения в суд с требованием об определении порядка общения и/или изменении места жительства Ребёнка. + +--- + +## СТАТЬЯ 21. ТРАНСПОРТНЫЕ РАСХОДЫ + +21.1. Расходы на транспортировку Ребёнка к Отцу (в том числе железнодорожные, авиабилеты, такси) несёт **Отец**. + +21.2. Расходы на возврат Ребёнка к Матери несёт **Отец**. + +21.3. В случае необходимости сопровождения Ребёнка взрослым (до достижения Ребёнком возраста самостоятельного передвижения) расходы на сопровождающего несёт Сторона, которая его направляет. + +21.4. При изменении места жительства Матери с Ребёнком на расстояние более 200 км от текущего места жительства дополнительные транспортные расходы, возникшие в связи с таким переездом, несёт **Мать**. + +--- + +## СТАТЬЯ 22. ДОСТУП К ИНФОРМАЦИИ О РЕБЁНКЕ + +22.1. Отец имеет право на получение информации о Ребёнке непосредственно из следующих организаций: + +— образовательных организаций (школа, детский сад, секции, кружки); +— медицинских организаций (поликлиника, больница, стоматология); +— иных организаций, оказывающих услуги Ребёнку. + +22.2. Мать обязуется сообщить Отцу наименования и адреса всех организаций, которые посещает Ребёнок, в течение семи (7) дней с момента начала посещения. + +22.3. Отец имеет право: + +— посещать родительские собрания и консультации с педагогами; +— получать информацию об успеваемости (оценки, табель, характеристики) непосредственно из образовательной организации; +— присутствовать на медицинских приёмах, обследованиях и консультациях; +— получать копии медицинских документов (карта, результаты анализов, выписки). + +22.4. Мать обязуется уведомлять Отца о плановых медицинских осмотрах, прививках и обследованиях не менее чем за семь (7) дней до их проведения. + +22.5. Отец вправе подать заявление в образовательную/медицинскую организацию о направлении ему информации о Ребёнке на его электронную почту или в личный кабинет. Мать обязуется не препятствовать такому запросу. + +22.6. **Электронный дневник.** Если образовательная организация использует электронный дневник, портал или личный кабинет, Мать обязуется предоставить Отцу доступ (логин/пароль или приглашение) в течение семи (7) дней с момента подключения. При невозможности предоставления отдельного доступа Мать ежемесячно направляет Отцу отчёт об успеваемости и посещаемости Ребёнка. + +--- + +## СТАТЬЯ 23. ЗАПРЕТ НА ОГРАНИЧЕНИЕ ВЫЕЗДА + +23.1. Стороны обязуются не подавать заявление о несогласии на выезд Ребёнка из РФ (ст.21 ФЗ от 15.08.1996 № 114-ФЗ) без разумных оснований и без предварительной попытки урегулировать вопрос с другой Стороной. + +23.2. В случае подачи такого заявления без разумных оснований или без попытки досудебного урегулирования другая Сторона вправе обратиться в суд с требованием об отмене запрета и взыскании убытков (стоимость пропавших туров, билетов, моральный вред). + +23.3. Настоящая статья не ограничивает право Стороны на подачу заявления о несогласии при наличии **реальной угрозы невозвращения Ребёнка**, подтверждённой доказательствами. + +--- + +## СТАТЬЯ 24. ЗАКЛЮЧИТЕЛЬНЫЕ ПОЛОЖЕНИЯ + +24.1. Настоящее Соглашение вступает в силу с момента его нотариального удостоверения и действует до достижения несовершеннолетним Кривовым Святославом Дмитриевичем возраста восемнадцати (18) лет, за исключением положений об алиментах на обучение, которые действуют до завершения обучения ребёнка, но не более чем до достижения им возраста двадцати трёх (23) лет при условии обучения по очной форме. + +24.2. Изменения и дополнения к настоящему Соглашению действительны только в случае их оформления в письменной форме, подписания обеими Сторонами и нотариального удостоверения. + +24.3. Досрочное расторжение настоящего Соглашения допускается по взаимному согласию Сторон, оформленному в нотариальном порядке, либо по решению суда в случаях, предусмотренных законодательством Российской Федерации. + +24.4. Если какое-либо положение настоящего Соглашения будет признано недействительным или не имеющим юридической силы, это не влечёт недействительности остальных положений Соглашения, которые сохраняют свою силу. + +24.5. По всем вопросам, не урегулированным настоящим Соглашением, Стороны руководствуются действующим законодательством Российской Федерации. + +24.6. Настоящее Соглашение составлено на русском языке в трёх (3) экземплярах, имеющих одинаковую юридическую силу: по одному экземпляру для каждой из Сторон и один экземпляр для хранения в делах нотариуса. + +24.7. Стороны подтверждают, что: +— текст настоящего Соглашения прочитан ими лично и понятен в полном объёме; +— правовые последствия заключения настоящего Соглашения им разъяснены нотариусом; +— настоящее Соглашение подписано ими добровольно, без какого-либо принуждения, давления или заблуждения; +— настоящее Соглашение соответствует их действительной воле и заключено в интересах их несовершеннолетнего ребёнка. + +--- + +## ПОДПИСИ СТОРОН + +**ОТЕЦ:** + +Кривов Дмитрий Евгеньевич + +Подпись: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ + +Дата: «\_\_\_» \_\_\_\_\_\_\_\_\_\_\_\_\_\_ 20\_\_ г. + +**МАТЬ:** + +Кривова Татьяна Николаевна + +Подпись: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ + +Дата: «\_\_\_» \_\_\_\_\_\_\_\_\_\_\_\_\_\_ 20\_\_ г. + +--- + +## УДОСТОВЕРИТЕЛЬНАЯ НАДПИСЬ НОТАРИУСА + +город Воронеж, Воронежская область, Российская Федерация + +«\_\_\_» \_\_\_\_\_\_\_\_\_\_\_\_\_\_ 20\_\_ года + +Настоящее соглашение удостоверено мной, \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ (фамилия, имя, отчество нотариуса), нотариусом нотариального округа город Воронеж Воронежской области. + +Соглашение подписано Сторонами в моём присутствии. + +Личности Сторон установлены на основании предъявленных паспортов граждан Российской Федерации. Дееспособность Сторон проверена. + +Содержание статей 34, 35, 38, 40, 42 Семейного кодекса Российской Федерации, статей 244–256 Гражданского кодекса Российской Федерации Сторонам разъяснено. + +Настоящее соглашение в части алиментных обязательств соответствует требованиям статей 99–100 Семейного кодекса Российской Федерации и имеет силу исполнительного листа. + +Зарегистрировано в реестре за № \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ + +Взыскано по тарифу: \_\_\_\_\_\_\_\_\_\_ (\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_) рублей. + +Нотариус: \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ / \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ + (подпись) (Ф.И.О.) + +М.П. \ No newline at end of file diff --git a/ттт b/ттт new file mode 100644 index 0000000..e69de29