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

This commit is contained in:
2026-04-27 23:36:28 +02:00
parent 199d871923
commit 44355deeb3
96 changed files with 15015 additions and 309 deletions

View 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())