feat: add parser source dashboard and scheduling
All checks were successful
CI/CD Pipeline / Code Quality Checks (pull_request) Successful in 1m6s
CI/CD Pipeline / Run Tests (pull_request) Successful in 1m18s
CI/CD Pipeline / Build Docker Images (pull_request) Has been skipped
CI/CD Pipeline / Push to Gitea Registry (pull_request) Has been skipped
All checks were successful
CI/CD Pipeline / Code Quality Checks (pull_request) Successful in 1m6s
CI/CD Pipeline / Run Tests (pull_request) Successful in 1m18s
CI/CD Pipeline / Build Docker Images (pull_request) Has been skipped
CI/CD Pipeline / Push to Gitea Registry (pull_request) Has been skipped
This commit is contained in:
753
tests/apps/parsers/test_views.py
Normal file
753
tests/apps/parsers/test_views.py
Normal file
@@ -0,0 +1,753 @@
|
||||
"""Tests for parsers API views."""
|
||||
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from apps.parsers.models import ParserLoadLog
|
||||
from apps.user.services import UserService
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from tests.apps.parsers.factories import (
|
||||
GenericParserRecordFactory,
|
||||
IndustrialCertificateRecordFactory,
|
||||
InspectionRecordFactory,
|
||||
ManufacturerRecordFactory,
|
||||
ParserLoadLogFactory,
|
||||
)
|
||||
from tests.apps.user.factories import UserFactory
|
||||
|
||||
|
||||
class ParserApiTest(APITestCase):
|
||||
"""Tests for parsers API."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = UserFactory.create_user()
|
||||
self.tokens = UserService.get_tokens_for_user(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.tokens['access']}")
|
||||
|
||||
def test_source_list_contains_existing_and_new_parsers(self):
|
||||
"""Test source catalog exposes existing and new parser slices."""
|
||||
response = self.client.get(reverse("api_v1:parsers:source-list"))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
keys = {item["key"] for item in response.data["data"]}
|
||||
self.assertIn("industrial", keys)
|
||||
self.assertIn("trudvsem", keys)
|
||||
self.assertIn("fedresurs_bankruptcy", keys)
|
||||
sources = {item["key"]: item for item in response.data["data"]}
|
||||
self.assertEqual(sources["industrial"]["mode"], "native_api")
|
||||
self.assertEqual(sources["fedresurs_bankruptcy"]["mode"], "official_api")
|
||||
self.assertEqual(
|
||||
sources["fedresurs_bankruptcy"]["status"],
|
||||
"implemented",
|
||||
)
|
||||
self.assertEqual(
|
||||
sources["fedresurs_bankruptcy"]["upstream_url"],
|
||||
"https://bankrot.fedresurs.ru/",
|
||||
)
|
||||
self.assertFalse(
|
||||
any(item["mode"] == "file_import" for item in sources.values())
|
||||
)
|
||||
self.assertEqual(sources["fns_financial"]["owner"], "Сергей")
|
||||
self.assertTrue(sources["fns_financial"]["supports_file_upload"])
|
||||
self.assertTrue(sources["fedresurs_bankruptcy"]["supports_file_upload"])
|
||||
upload_sources = {
|
||||
key for key, source in sources.items() if source["supports_file_upload"]
|
||||
}
|
||||
self.assertEqual(upload_sources, {"fns_financial", "fedresurs_bankruptcy"})
|
||||
self.assertFalse(sources["trudvsem"]["supports_file_upload"])
|
||||
self.assertFalse(sources["mpt_products"]["supports_file_upload"])
|
||||
self.assertEqual(sources["mpt_products"]["upload_url"], "")
|
||||
self.assertEqual(
|
||||
sources["fns_financial"]["result_list_url"],
|
||||
"/api/v1/fns/reports/",
|
||||
)
|
||||
self.assertEqual(
|
||||
sources["fns_financial"]["result_detail_url"],
|
||||
"/api/v1/fns/reports/{id}/",
|
||||
)
|
||||
self.assertEqual(
|
||||
sources["fns_financial"]["upload_url"],
|
||||
"/api/v1/fns/upload/",
|
||||
)
|
||||
self.assertEqual(
|
||||
sources["procurements_44fz"]["result_list_url"],
|
||||
"/api/v1/zakupki/",
|
||||
)
|
||||
self.assertEqual(
|
||||
sources["procurements_223fz"]["result_list_url"],
|
||||
"/api/v1/zakupki/",
|
||||
)
|
||||
self.assertEqual(sources["contracts"]["result_list_url"], "/api/v1/zakupki/")
|
||||
|
||||
def test_dashboard_page_is_available_outside_admin(self):
|
||||
"""Test dashboard HTML shell is served outside Django admin."""
|
||||
response = self.client.get(reverse("dashboard"))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertContains(response, "Parser Dashboard")
|
||||
self.assertContains(response, 'name="username"')
|
||||
self.assertContains(response, "color-scheme: dark")
|
||||
self.assertContains(response, "refreshTokenButton")
|
||||
self.assertContains(response, "sourceRoutePanel")
|
||||
self.assertContains(response, "uploadPanel")
|
||||
self.assertContains(response, "uploadSources")
|
||||
self.assertContains(response, "registryUploadPanel")
|
||||
self.assertContains(response, "Загрузка организаций ОПК")
|
||||
self.assertContains(response, "/api/v1/registers/upload/")
|
||||
self.assertContains(response, "/api/v1/registers/registries/")
|
||||
self.assertContains(response, "Внешняя БД и выгрузка данных")
|
||||
self.assertContains(response, "/api/v1/exchange/copy/")
|
||||
self.assertContains(response, "Выгрузка организаций реестра в .bin")
|
||||
self.assertContains(response, "/api/v1/backups/export/")
|
||||
self.assertContains(response, "Источники API")
|
||||
self.assertContains(response, "Загрузить реестр")
|
||||
self.assertContains(response, "/api/v1/users/token/refresh/")
|
||||
|
||||
def test_dashboard_nested_routes_render_same_html_shell(self):
|
||||
"""Test direct source and item dashboard URLs render the HTML app."""
|
||||
source_response = self.client.get("/dashboard/trudvsem")
|
||||
item_response = self.client.get("/dashboard/trudvsem/1")
|
||||
|
||||
self.assertEqual(source_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(item_response.status_code, status.HTTP_200_OK)
|
||||
self.assertContains(source_response, "Parser Dashboard")
|
||||
self.assertContains(item_response, "Parser Dashboard")
|
||||
|
||||
@patch("apps.parsers.views.tasks.parse_trudvsem_vacancies.delay")
|
||||
def test_run_parser_starts_celery_task(self, mock_delay):
|
||||
"""Test parser run endpoint starts task through Celery."""
|
||||
mock_delay.return_value = Mock(id="task-123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("api_v1:parsers:run-parser", kwargs={"source_key": "trudvsem"}),
|
||||
{"limit": 10, "text": "инженер"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["data"]["task_id"], "task-123")
|
||||
mock_delay.assert_called_once()
|
||||
self.assertEqual(mock_delay.call_args.kwargs["limit"], 10)
|
||||
self.assertEqual(mock_delay.call_args.kwargs["user_id"], self.user.id)
|
||||
|
||||
@patch("apps.parsers.views.tasks.parse_industrial_production.delay")
|
||||
def test_run_existing_parser_passes_user_id(self, mock_delay):
|
||||
"""Test existing parser tasks keep API job ownership."""
|
||||
mock_delay.return_value = Mock(id="task-existing")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("api_v1:parsers:run-parser", kwargs={"source_key": "industrial"}),
|
||||
{},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["data"]["task_id"], "task-existing")
|
||||
self.assertEqual(mock_delay.call_args.kwargs["user_id"], self.user.id)
|
||||
|
||||
@patch("apps.parsers.views.tasks.sync_inspections.delay")
|
||||
def test_run_sync_inspections_passes_control_params(self, mock_delay):
|
||||
"""Test sync parser accepts bounded run controls from API."""
|
||||
mock_delay.return_value = Mock(id="task-sync")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"api_v1:parsers:run-parser",
|
||||
kwargs={"source_key": "sync_inspections"},
|
||||
),
|
||||
{
|
||||
"max_months_per_law": 1,
|
||||
"start_year": 2026,
|
||||
"start_month": 1,
|
||||
"include_fz294": True,
|
||||
"include_fz248": False,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["data"]["task_id"], "task-sync")
|
||||
self.assertEqual(mock_delay.call_args.kwargs["max_months_per_law"], 1)
|
||||
self.assertEqual(mock_delay.call_args.kwargs["start_year"], 2026)
|
||||
self.assertEqual(mock_delay.call_args.kwargs["start_month"], 1)
|
||||
self.assertTrue(mock_delay.call_args.kwargs["include_fz294"])
|
||||
self.assertFalse(mock_delay.call_args.kwargs["include_fz248"])
|
||||
|
||||
@patch("apps.parsers.views.tasks.parse_fedresurs_bankruptcy.delay")
|
||||
def test_run_upstream_parser_uses_catalog_source_without_file_url(self, mock_delay):
|
||||
"""Test upstream parsers do not require manual file URLs."""
|
||||
mock_delay.return_value = Mock(id="fedresurs-task")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"api_v1:parsers:run-parser",
|
||||
kwargs={"source_key": "fedresurs_bankruptcy"},
|
||||
),
|
||||
{},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["data"]["task_id"], "fedresurs-task")
|
||||
self.assertNotIn("file_url", mock_delay.call_args.kwargs)
|
||||
|
||||
def test_run_file_parser_rejects_private_file_url(self):
|
||||
"""Test parser run endpoint blocks private worker-side URLs."""
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"api_v1:parsers:run-parser",
|
||||
kwargs={"source_key": "fedresurs_bankruptcy"},
|
||||
),
|
||||
{"file_url": "https://127.0.0.1/private.xml"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_run_parser_rejects_proxy_override_for_regular_user(self):
|
||||
"""Test non-staff users cannot route parser traffic through own proxy."""
|
||||
response = self.client.post(
|
||||
reverse("api_v1:parsers:run-parser", kwargs={"source_key": "industrial"}),
|
||||
{"proxies": ["http://proxy.example:8080"]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(
|
||||
response.data["errors"][0]["code"],
|
||||
"proxy_override_forbidden",
|
||||
)
|
||||
|
||||
@patch("apps.parsers.views._save_uploaded_parser_file")
|
||||
@patch("apps.parsers.views.tasks.import_parser_upload.delay")
|
||||
def test_upload_registry_file_starts_celery_task(
|
||||
self,
|
||||
mock_delay,
|
||||
mock_save_upload,
|
||||
):
|
||||
"""Test manual registry upload starts Celery import task."""
|
||||
mock_delay.return_value = Mock(id="upload-task")
|
||||
mock_save_upload.return_value = "parser_uploads/source.csv"
|
||||
uploaded_file = SimpleUploadedFile(
|
||||
"source.csv",
|
||||
b"inn,ogrn,name\n1234567890,1234567890123,Test Org\n",
|
||||
content_type="text/csv",
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"api_v1:parsers:upload-parser-data",
|
||||
kwargs={"source_key": "fns_financial"},
|
||||
),
|
||||
{"file": uploaded_file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["data"]["task_id"], "upload-task")
|
||||
mock_delay.assert_called_once_with(
|
||||
source_key="fns_financial",
|
||||
storage_path="parser_uploads/source.csv",
|
||||
file_name="source.csv",
|
||||
user_id=self.user.id,
|
||||
)
|
||||
|
||||
@patch("apps.parsers.views._save_uploaded_parser_file")
|
||||
@patch("apps.parsers.views.tasks.import_parser_upload.delay")
|
||||
def test_source_upload_registry_file_starts_celery_task(
|
||||
self,
|
||||
mock_delay,
|
||||
mock_save_upload,
|
||||
):
|
||||
"""Test source upload endpoint follows dev-compatible FNS route."""
|
||||
mock_delay.return_value = Mock(id="upload-task")
|
||||
mock_save_upload.return_value = "parser_uploads/source.csv"
|
||||
uploaded_file = SimpleUploadedFile(
|
||||
"source.csv",
|
||||
b"inn,ogrn,name\n1234567890,1234567890123,Test Org\n",
|
||||
content_type="text/csv",
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/fns/upload/",
|
||||
{"file": uploaded_file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["data"]["task_id"], "upload-task")
|
||||
mock_delay.assert_called_once_with(
|
||||
source_key="fns_financial",
|
||||
storage_path="parser_uploads/source.csv",
|
||||
file_name="source.csv",
|
||||
user_id=self.user.id,
|
||||
)
|
||||
|
||||
def test_source_upload_absent_for_non_upload_source(self):
|
||||
"""Test source API does not expose upload where source has no manual import."""
|
||||
response = self.client.post("/api/v1/trudvsem/vacancies/upload/", {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_upload_registry_file_rejects_non_upload_source(self):
|
||||
"""Test manual upload is limited to sources that support file import."""
|
||||
uploaded_file = SimpleUploadedFile(
|
||||
"source.csv",
|
||||
b"inn,name\n1234567890,Test Org\n",
|
||||
content_type="text/csv",
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"api_v1:parsers:upload-parser-data",
|
||||
kwargs={"source_key": "trudvsem"},
|
||||
),
|
||||
{"file": uploaded_file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.data["errors"][0]["code"], "upload_not_supported")
|
||||
|
||||
def test_upload_registry_file_rejects_api_only_registry_source(self):
|
||||
"""Test API/upstream sources do not expose manual file imports."""
|
||||
uploaded_file = SimpleUploadedFile(
|
||||
"source.csv",
|
||||
b"inn,name\n1234567890,Test Org\n",
|
||||
content_type="text/csv",
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"api_v1:parsers:upload-parser-data",
|
||||
kwargs={"source_key": "mpt_products"},
|
||||
),
|
||||
{"file": uploaded_file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.data["errors"][0]["code"], "upload_not_supported")
|
||||
|
||||
def test_load_logs_list(self):
|
||||
"""Test load logs endpoint."""
|
||||
ParserLoadLogFactory(source=ParserLoadLog.Source.FNS_FINANCIAL, batch_id=1)
|
||||
|
||||
response = self.client.get(reverse("api_v1:parsers:load-log-list"))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["data"]), 1)
|
||||
|
||||
def test_load_logs_list_rejects_invalid_limit(self):
|
||||
"""Test load logs endpoint validates limit query parameter."""
|
||||
response = self.client.get(
|
||||
reverse("api_v1:parsers:load-log-list"),
|
||||
{"limit": "bad"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_frontend_source_card_aliases(self):
|
||||
"""Test dev frontend source-card API aliases are present."""
|
||||
GenericParserRecordFactory(source=ParserLoadLog.Source.FNS_FINANCIAL)
|
||||
ParserLoadLogFactory(source=ParserLoadLog.Source.FNS_FINANCIAL, batch_id=1)
|
||||
|
||||
list_response = self.client.get(reverse("api_v1:sources:source-cards-list"))
|
||||
statuses_response = self.client.get(reverse("api_v1:sources:source-statuses"))
|
||||
detail_response = self.client.get(
|
||||
reverse(
|
||||
"api_v1:sources:source-card-detail",
|
||||
kwargs={"slug": "financial-indicators"},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(list_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(statuses_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(detail_response.status_code, status.HTTP_200_OK)
|
||||
slugs = {item["slug"] for item in list_response.data["data"]}
|
||||
self.assertIn("financial-indicators", slugs)
|
||||
self.assertIn("manufacturers-and-products", slugs)
|
||||
self.assertIn("public-procurements", slugs)
|
||||
self.assertEqual(detail_response.data["data"]["slug"], "financial-indicators")
|
||||
self.assertIn("source_items", detail_response.data["data"])
|
||||
|
||||
def test_frontend_parsing_settings_alias_get_and_patch(self):
|
||||
"""Test dev-compatible parsing settings endpoint."""
|
||||
url = reverse("api_v1:parsing:parsing-settings")
|
||||
|
||||
get_response = self.client.get(url)
|
||||
patch_response = self.client.patch(
|
||||
url,
|
||||
{"planned_inspections": "weekly"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(patch_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(patch_response.data["planned_inspections"], "weekly")
|
||||
|
||||
def test_frontend_source_refresh_alias_starts_task(self):
|
||||
"""Test dev-compatible source refresh endpoint starts mapped Celery task."""
|
||||
task = Mock()
|
||||
task.delay.return_value = Mock(id="compat-refresh-task")
|
||||
|
||||
with patch.dict(
|
||||
"apps.parsers.frontend_compat.TASKS_BY_NAME",
|
||||
{"apps.parsers.tasks.parse_trudvsem_vacancies": task},
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"api_v1:sources:source-card-refresh",
|
||||
kwargs={"slug": "labor-vacancies"},
|
||||
),
|
||||
{"params": {"limit": 5, "text": "engineer"}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["task_id"], "compat-refresh-task")
|
||||
task.delay.assert_called_once_with(
|
||||
limit=5,
|
||||
text="engineer",
|
||||
user_id=self.user.id,
|
||||
)
|
||||
|
||||
def test_frontend_source_refresh_rejects_nested_proxy_override(self):
|
||||
"""Test source refresh alias keeps proxy override restricted."""
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"api_v1:sources:source-card-refresh",
|
||||
kwargs={"slug": "labor-vacancies"},
|
||||
),
|
||||
{"params": {"proxies": ["http://proxy.example"]}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.data["errors"][0]["code"], "proxy_override_forbidden")
|
||||
|
||||
def test_frontend_system_logs_aliases(self):
|
||||
"""Test dev-compatible system logs list/detail/export endpoints."""
|
||||
log = ParserLoadLogFactory(
|
||||
source=ParserLoadLog.Source.FNS_FINANCIAL,
|
||||
batch_id=777,
|
||||
records_count=3,
|
||||
)
|
||||
|
||||
list_response = self.client.get(
|
||||
reverse("api_v1:system:parser-logs-list"),
|
||||
{"search": "777"},
|
||||
)
|
||||
detail_response = self.client.get(
|
||||
reverse("api_v1:system:parser-logs-detail", kwargs={"pk": log.id})
|
||||
)
|
||||
export_response = self.client.get(
|
||||
reverse("api_v1:system:parser-logs-export"),
|
||||
{"search": "777"},
|
||||
)
|
||||
|
||||
self.assertEqual(list_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(list_response.data["count"], 1)
|
||||
self.assertEqual(
|
||||
list_response.data["results"][0]["source"], "financial-indicators"
|
||||
)
|
||||
self.assertEqual(detail_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(detail_response.data["id"], log.id)
|
||||
self.assertEqual(export_response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("text/csv", export_response["Content-Type"])
|
||||
|
||||
def test_generic_records_list_filters_by_source(self):
|
||||
"""Test generic records endpoint filters by source."""
|
||||
GenericParserRecordFactory(source=ParserLoadLog.Source.TRUDVSEM)
|
||||
GenericParserRecordFactory(source=ParserLoadLog.Source.FNS_FINANCIAL)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v1:parsers:generic-record-list"),
|
||||
{"source": ParserLoadLog.Source.TRUDVSEM},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["data"]), 1)
|
||||
self.assertEqual(
|
||||
response.data["data"][0]["source"], ParserLoadLog.Source.TRUDVSEM
|
||||
)
|
||||
|
||||
def test_generic_records_list_filters_by_id(self):
|
||||
"""Test generic records endpoint can support dashboard detail view."""
|
||||
target = GenericParserRecordFactory(source=ParserLoadLog.Source.TRUDVSEM)
|
||||
GenericParserRecordFactory(source=ParserLoadLog.Source.TRUDVSEM)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v1:parsers:generic-record-list"),
|
||||
{"source": ParserLoadLog.Source.TRUDVSEM, "id": target.id},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["data"]), 1)
|
||||
self.assertEqual(response.data["data"][0]["id"], target.id)
|
||||
|
||||
def test_records_list_returns_old_industrial_records(self):
|
||||
"""Test records endpoint still exposes old industrial parser data."""
|
||||
target = IndustrialCertificateRecordFactory()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v1:parsers:generic-record-list"),
|
||||
{"source": ParserLoadLog.Source.INDUSTRIAL, "id": target.id},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["data"]), 1)
|
||||
record = response.data["data"][0]
|
||||
self.assertEqual(record["source"], ParserLoadLog.Source.INDUSTRIAL)
|
||||
self.assertEqual(record["external_id"], target.certificate_number)
|
||||
self.assertEqual(record["organisation_name"], target.organisation_name)
|
||||
self.assertEqual(
|
||||
record["payload"]["certificate_number"], target.certificate_number
|
||||
)
|
||||
|
||||
def test_records_list_returns_old_manufacturer_records(self):
|
||||
"""Test records endpoint still exposes old manufacturer parser data."""
|
||||
target = ManufacturerRecordFactory()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v1:parsers:generic-record-list"),
|
||||
{"source": ParserLoadLog.Source.MANUFACTURES, "id": target.id},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
record = response.data["data"][0]
|
||||
self.assertEqual(record["source"], ParserLoadLog.Source.MANUFACTURES)
|
||||
self.assertEqual(record["external_id"], target.inn)
|
||||
self.assertEqual(record["organisation_name"], target.full_legal_name)
|
||||
|
||||
def test_records_list_returns_old_inspection_records(self):
|
||||
"""Test records endpoint still exposes old inspections parser data."""
|
||||
target = InspectionRecordFactory()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("api_v1:parsers:generic-record-list"),
|
||||
{"source": ParserLoadLog.Source.INSPECTIONS, "id": target.id},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
record = response.data["data"][0]
|
||||
self.assertEqual(record["source"], ParserLoadLog.Source.INSPECTIONS)
|
||||
self.assertEqual(record["external_id"], target.registration_number)
|
||||
self.assertEqual(record["organisation_name"], target.organisation_name)
|
||||
self.assertEqual(record["status"], target.status)
|
||||
|
||||
def test_generic_records_list_rejects_invalid_id(self):
|
||||
"""Test generic records endpoint validates id filter."""
|
||||
response = self.client.get(
|
||||
reverse("api_v1:parsers:generic-record-list"),
|
||||
{"id": "bad"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.data["errors"][0]["code"], "invalid_record_id")
|
||||
|
||||
def test_generic_records_list_rejects_invalid_limit(self):
|
||||
"""Test generic records endpoint validates limit query parameter."""
|
||||
response = self.client.get(
|
||||
reverse("api_v1:parsers:generic-record-list"),
|
||||
{"limit": "bad"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_source_result_list_filters_generic_records(self):
|
||||
"""Test source result API exposes get_list with query params."""
|
||||
target = GenericParserRecordFactory(
|
||||
source=ParserLoadLog.Source.FNS_FINANCIAL,
|
||||
inn="7701000001",
|
||||
organisation_name="Target Org",
|
||||
)
|
||||
GenericParserRecordFactory(
|
||||
source=ParserLoadLog.Source.FNS_FINANCIAL,
|
||||
inn="7701000002",
|
||||
)
|
||||
GenericParserRecordFactory(source=ParserLoadLog.Source.TRUDVSEM)
|
||||
|
||||
response = self.client.get(
|
||||
"/api/v1/fns/reports/",
|
||||
{"inn": "7701000001", "page_size": 10, "include_payload": "false"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["data"]), 1)
|
||||
self.assertEqual(response.data["data"][0]["id"], target.id)
|
||||
self.assertEqual(response.data["data"][0]["payload"], {})
|
||||
self.assertEqual(response.data["meta"]["pagination"]["total_count"], 1)
|
||||
|
||||
def test_source_result_get_filters_generic_record(self):
|
||||
"""Test source result API exposes get with query params."""
|
||||
target = GenericParserRecordFactory(
|
||||
source=ParserLoadLog.Source.FNS_FINANCIAL,
|
||||
inn="7701000001",
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/v1/fns/reports/{target.id}/",
|
||||
{"inn": "7701000001"},
|
||||
)
|
||||
missing_response = self.client.get(
|
||||
f"/api/v1/fns/reports/{target.id}/",
|
||||
{"inn": "7701000002"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["data"]["id"], target.id)
|
||||
self.assertEqual(missing_response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_source_result_list_filters_native_records(self):
|
||||
"""Test source result API exposes old native records with the same result DTO."""
|
||||
target = ManufacturerRecordFactory(inn="7701000001")
|
||||
ManufacturerRecordFactory(inn="7701000002")
|
||||
|
||||
response = self.client.get(
|
||||
"/api/v1/minpromtorg/manufacturers/",
|
||||
{"inn": "7701000001", "page_size": 10},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["data"]), 1)
|
||||
self.assertEqual(response.data["data"][0]["id"], target.id)
|
||||
self.assertEqual(
|
||||
response.data["data"][0]["organisation_name"],
|
||||
target.full_legal_name,
|
||||
)
|
||||
|
||||
def test_zakupki_result_route_groups_eis_sources(self):
|
||||
"""Test EIS data is exposed through one dev-compatible zakupki route."""
|
||||
target = GenericParserRecordFactory(
|
||||
source=ParserLoadLog.Source.PROCUREMENTS_44FZ,
|
||||
inn="7701000001",
|
||||
organisation_name="44FZ Org",
|
||||
)
|
||||
GenericParserRecordFactory(
|
||||
source=ParserLoadLog.Source.PROCUREMENTS_223FZ,
|
||||
inn="7701000002",
|
||||
organisation_name="223FZ Org",
|
||||
)
|
||||
GenericParserRecordFactory(
|
||||
source=ParserLoadLog.Source.CONTRACTS,
|
||||
inn="7701000003",
|
||||
organisation_name="Contract Org",
|
||||
)
|
||||
GenericParserRecordFactory(source=ParserLoadLog.Source.FNS_FINANCIAL)
|
||||
|
||||
grouped_response = self.client.get("/api/v1/zakupki/", {"page_size": 10})
|
||||
filtered_response = self.client.get(
|
||||
"/api/v1/zakupki/",
|
||||
{"source": ParserLoadLog.Source.PROCUREMENTS_44FZ, "page_size": 10},
|
||||
)
|
||||
detail_response = self.client.get(f"/api/v1/zakupki/{target.id}/")
|
||||
|
||||
self.assertEqual(grouped_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
{record["source"] for record in grouped_response.data["data"]},
|
||||
{
|
||||
ParserLoadLog.Source.PROCUREMENTS_44FZ,
|
||||
ParserLoadLog.Source.PROCUREMENTS_223FZ,
|
||||
ParserLoadLog.Source.CONTRACTS,
|
||||
},
|
||||
)
|
||||
self.assertEqual(filtered_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(filtered_response.data["data"]), 1)
|
||||
self.assertEqual(filtered_response.data["data"][0]["id"], target.id)
|
||||
self.assertEqual(detail_response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(detail_response.data["data"]["id"], target.id)
|
||||
|
||||
def test_invented_eis_subroutes_are_not_exposed(self):
|
||||
"""Test public API does not expose invented EIS subresource routes."""
|
||||
self.assertEqual(
|
||||
self.client.get("/api/v1/zakupki/procurements-44fz/").status_code,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.get("/api/v1/zakupki/procurements-223fz/").status_code,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.get("/api/v1/zakupki/contracts/").status_code,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.post("/api/v1/zakupki/upload/", {}).status_code,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
def test_dashboard_data_contains_sources_jobs_and_groups(self):
|
||||
"""Test dashboard data endpoint returns management payload."""
|
||||
ParserLoadLogFactory(source=ParserLoadLog.Source.FNS_FINANCIAL, batch_id=1)
|
||||
|
||||
response = self.client.get(reverse("api_v1:parsers:dashboard-data"))
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
payload = response.data["data"]
|
||||
self.assertIn("sources", payload)
|
||||
self.assertIn("jobs", payload)
|
||||
self.assertIn("schedules", payload)
|
||||
self.assertEqual(payload["api"]["frontend_sources"], "/api/v1/sources/")
|
||||
self.assertEqual(payload["api"]["system_logs"], "/api/v1/system/logs/")
|
||||
financial_keys = {
|
||||
item["key"] for item in payload["groups"]["financial_reports"]
|
||||
}
|
||||
self.assertIn("fns_financial", financial_keys)
|
||||
upload_keys = {item["key"] for item in payload["groups"]["uploads"]}
|
||||
self.assertEqual(upload_keys, {"fns_financial", "fedresurs_bankruptcy"})
|
||||
|
||||
def test_schedule_create_configures_celery_beat_task(self):
|
||||
"""Test parser schedule endpoint creates django-celery-beat task."""
|
||||
response = self.client.post(
|
||||
reverse("api_v1:parsers:schedule-list"),
|
||||
{
|
||||
"source_key": "trudvsem",
|
||||
"every": 6,
|
||||
"period": "hours",
|
||||
"limit": 25,
|
||||
"text": "инженер",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data["data"]["source_key"], "trudvsem")
|
||||
|
||||
periodic_task = PeriodicTask.objects.get(
|
||||
name=f"parser:trudvsem:user:{self.user.id}"
|
||||
)
|
||||
kwargs = json.loads(periodic_task.kwargs)
|
||||
self.assertEqual(kwargs["user_id"], self.user.id)
|
||||
self.assertEqual(kwargs["limit"], 25)
|
||||
self.assertEqual(periodic_task.interval.every, 6)
|
||||
|
||||
def test_schedule_update_and_delete(self):
|
||||
"""Test parser schedule can be paused and removed."""
|
||||
create_response = self.client.post(
|
||||
reverse("api_v1:parsers:schedule-list"),
|
||||
{"source_key": "industrial", "every": 1, "period": "days"},
|
||||
format="json",
|
||||
)
|
||||
schedule_id = create_response.data["data"]["id"]
|
||||
|
||||
update_response = self.client.patch(
|
||||
reverse("api_v1:parsers:schedule-detail", kwargs={"pk": schedule_id}),
|
||||
{"enabled": False},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(update_response.status_code, status.HTTP_200_OK)
|
||||
self.assertFalse(update_response.data["data"]["enabled"])
|
||||
|
||||
delete_response = self.client.delete(
|
||||
reverse("api_v1:parsers:schedule-detail", kwargs={"pk": schedule_id})
|
||||
)
|
||||
|
||||
self.assertEqual(delete_response.status_code, status.HTTP_200_OK)
|
||||
self.assertFalse(PeriodicTask.objects.filter(pk=schedule_id).exists())
|
||||
Reference in New Issue
Block a user