feat: export state corp package from backup endpoint
This commit is contained in:
@@ -1,21 +1,13 @@
|
||||
"""Tests for async backups export API."""
|
||||
"""Tests for backups export API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from apps.backups.models import BackupExportJob
|
||||
from apps.backups.services import (
|
||||
BackupExportError,
|
||||
BackupExportJobService,
|
||||
BackupRequestResult,
|
||||
)
|
||||
from django.db import IntegrityError
|
||||
from apps.exchange.state_corp_services import StateCorpExchangeError
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -23,7 +15,7 @@ from tests.apps.user.factories import UserFactory
|
||||
|
||||
|
||||
class BackupExportViewTest(APITestCase):
|
||||
"""Tests for async backup export endpoint."""
|
||||
"""Tests for synchronous ZIP export endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = UserFactory.create_user()
|
||||
@@ -38,167 +30,49 @@ class BackupExportViewTest(APITestCase):
|
||||
response = self.client.post(self.url, {}, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@patch("apps.backups.services.BackupExportJobService._enqueue_backup_task")
|
||||
def test_export_starts_job_when_absent(self, enqueue_mock):
|
||||
self.client.force_authenticate(self.admin)
|
||||
today = timezone.localdate()
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"actual_date": today.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["status"], "started")
|
||||
|
||||
job = BackupExportJob.objects.get(actual_date=today)
|
||||
self.assertEqual(job.status, BackupExportJob.Status.PENDING)
|
||||
self.assertEqual(response.data["task_id"], job.task_id)
|
||||
enqueue_mock.assert_called_once_with(job_id=job.id, task_id=job.task_id)
|
||||
|
||||
@patch("apps.backups.services.BackupExportJobService._enqueue_backup_task")
|
||||
def test_export_returns_wait_when_job_in_progress(self, enqueue_mock):
|
||||
today = timezone.localdate()
|
||||
BackupExportJob.objects.create(
|
||||
actual_date=today,
|
||||
status=BackupExportJob.Status.STARTED,
|
||||
task_id="task-running-1",
|
||||
@patch("apps.backups.views.StateCorpExchangeService.build_package")
|
||||
def test_export_returns_state_corp_zip_synchronously(self, build_package_mock):
|
||||
build_package_mock.return_value = SimpleNamespace(
|
||||
package_id="package-1",
|
||||
archive_name="state_corp_exchange.zip",
|
||||
bin_name="state_corp_exchange.bin",
|
||||
archive_bytes=b"zip-bytes",
|
||||
payload_counts={
|
||||
"organizations": 2,
|
||||
"industrial_products": 1,
|
||||
"prosecutor_checks": 0,
|
||||
"public_procurements": 0,
|
||||
"arbitration_cases": 0,
|
||||
},
|
||||
produced_at="2026-05-12T10:00:00+00:00",
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.admin)
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"actual_date": today.isoformat()},
|
||||
{"actual_date": "2026-05-12", "registry": "ignored-legacy-field"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["status"], "wait")
|
||||
self.assertEqual(response.data["task_id"], "task-running-1")
|
||||
enqueue_mock.assert_not_called()
|
||||
|
||||
@patch("apps.backups.services.BackupExportJobService._enqueue_backup_task")
|
||||
def test_export_returns_file_and_deletes_after_download(self, enqueue_mock):
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
archive_bytes = b"zip-content"
|
||||
archive_path = tmp_path / "backup.zip"
|
||||
archive_path.write_bytes(archive_bytes)
|
||||
|
||||
today = timezone.localdate()
|
||||
job = BackupExportJob.objects.create(
|
||||
actual_date=today,
|
||||
status=BackupExportJob.Status.SUCCESS,
|
||||
task_id="task-success-1",
|
||||
archive_path=str(archive_path),
|
||||
archive_filename="backup.zip",
|
||||
checksum_filename="backup.zip.sha256",
|
||||
checksum_sha256=hashlib.sha256(archive_bytes).hexdigest(),
|
||||
organizations_count=7,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.admin)
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"actual_date": today.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.content, archive_bytes)
|
||||
self.assertEqual(response["Content-Type"], "application/zip")
|
||||
self.assertIn(
|
||||
'attachment; filename="backup.zip"', response["Content-Disposition"]
|
||||
)
|
||||
self.assertEqual(response["X-Backup-Organizations"], "7")
|
||||
self.assertEqual(
|
||||
response["X-Backup-SHA256"],
|
||||
hashlib.sha256(archive_bytes).hexdigest(),
|
||||
)
|
||||
|
||||
self.assertFalse(archive_path.exists())
|
||||
self.assertFalse(BackupExportJob.objects.filter(id=job.id).exists())
|
||||
enqueue_mock.assert_not_called()
|
||||
|
||||
@patch("apps.backups.services.BackupExportJobService._enqueue_backup_task")
|
||||
def test_export_restarts_when_success_job_has_no_file(self, enqueue_mock):
|
||||
today = timezone.localdate()
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
missing_archive_path = Path(tmp_dir) / "non-existent-backup.zip"
|
||||
BackupExportJob.objects.create(
|
||||
actual_date=today,
|
||||
status=BackupExportJob.Status.SUCCESS,
|
||||
task_id="task-old",
|
||||
archive_path=str(missing_archive_path),
|
||||
archive_filename="non-existent-backup.zip",
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.admin)
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"actual_date": today.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["status"], "started")
|
||||
restarted_job = BackupExportJob.objects.get(actual_date=today)
|
||||
self.assertEqual(response.data["task_id"], restarted_job.task_id)
|
||||
enqueue_mock.assert_called_once_with(
|
||||
job_id=restarted_job.id,
|
||||
task_id=restarted_job.task_id,
|
||||
)
|
||||
|
||||
@patch("apps.backups.services.BackupExportJob.objects.create")
|
||||
def test_check_or_start_job_handles_integrity_race(self, create_mock):
|
||||
today = timezone.localdate()
|
||||
concurrent_job = BackupExportJob(
|
||||
actual_date=today,
|
||||
status=BackupExportJob.Status.STARTED,
|
||||
task_id="task-race-running",
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.content, b"zip-bytes")
|
||||
self.assertEqual(response["Content-Type"], "application/zip")
|
||||
self.assertIn(
|
||||
'attachment; filename="state_corp_exchange.zip"',
|
||||
response["Content-Disposition"],
|
||||
)
|
||||
concurrent_job.save()
|
||||
create_mock.side_effect = IntegrityError("duplicate key value")
|
||||
self.assertEqual(response["Content-Length"], "9")
|
||||
self.assertEqual(response["X-State-Corp-Package-Id"], "package-1")
|
||||
self.assertEqual(response["X-State-Corp-Bin-File"], "state_corp_exchange.bin")
|
||||
self.assertEqual(response["X-State-Corp-Organizations"], "2")
|
||||
self.assertEqual(response["X-Backup-Organizations"], "2")
|
||||
self.assertEqual(response["X-Backup-Actual-Date"], "2026-05-12")
|
||||
build_package_mock.assert_called_once_with(actual_date=date(2026, 5, 12))
|
||||
|
||||
with patch.object(
|
||||
BackupExportJobService,
|
||||
"_get_job_for_update",
|
||||
side_effect=[None, concurrent_job],
|
||||
):
|
||||
result = BackupExportJobService.check_or_start_job(
|
||||
actual_date=today,
|
||||
requested_by_id=self.admin.id,
|
||||
)
|
||||
|
||||
self.assertEqual(result.action, "wait")
|
||||
self.assertEqual(result.task_id, "task-race-running")
|
||||
|
||||
@patch("apps.backups.services.BackupExportJobService._enqueue_backup_task")
|
||||
def test_check_or_start_job_enqueues_on_commit(self, enqueue_mock):
|
||||
today = timezone.localdate()
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=False) as callbacks:
|
||||
result = BackupExportJobService.check_or_start_job(
|
||||
actual_date=today,
|
||||
requested_by_id=self.admin.id,
|
||||
)
|
||||
|
||||
self.assertEqual(result.action, "started")
|
||||
self.assertEqual(len(callbacks), 1)
|
||||
enqueue_mock.assert_not_called()
|
||||
|
||||
job = BackupExportJob.objects.get(actual_date=today)
|
||||
self.assertEqual(job.task_id, result.task_id)
|
||||
|
||||
callbacks[0]()
|
||||
enqueue_mock.assert_called_once_with(job_id=job.id, task_id=job.task_id)
|
||||
|
||||
@patch("apps.backups.views.BackupExportJobService.check_or_start_job")
|
||||
def test_export_returns_400_when_job_start_fails(self, check_or_start_mock):
|
||||
@patch("apps.backups.views.StateCorpExchangeService.build_package")
|
||||
def test_export_returns_400_when_package_build_fails(self, build_package_mock):
|
||||
self.client.force_authenticate(self.admin)
|
||||
check_or_start_mock.side_effect = BackupExportError("broken")
|
||||
build_package_mock.side_effect = StateCorpExchangeError("broken")
|
||||
|
||||
response = self.client.post(self.url, {}, format="json")
|
||||
|
||||
@@ -206,32 +80,3 @@ class BackupExportViewTest(APITestCase):
|
||||
self.assertEqual(
|
||||
response.data["errors"][0]["details"]["fields"]["backup"], ["broken"]
|
||||
)
|
||||
|
||||
@patch("apps.backups.views.BackupExportJobService.consume_ready_archive")
|
||||
@patch("apps.backups.views.BackupExportJobService.check_or_start_job")
|
||||
def test_export_returns_400_when_archive_consumption_fails(
|
||||
self,
|
||||
check_or_start_mock,
|
||||
consume_mock,
|
||||
):
|
||||
today = timezone.localdate()
|
||||
self.client.force_authenticate(self.admin)
|
||||
check_or_start_mock.return_value = BackupRequestResult(
|
||||
action="download",
|
||||
message="ready",
|
||||
actual_date=today,
|
||||
task_id="task-ready",
|
||||
)
|
||||
consume_mock.side_effect = BackupExportError("missing archive")
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{"actual_date": today.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data["errors"][0]["details"]["fields"]["backup"],
|
||||
["missing archive"],
|
||||
)
|
||||
|
||||
@@ -12,6 +12,8 @@ from unittest.mock import Mock, patch
|
||||
from zipfile import ZipFile
|
||||
|
||||
from apps.exchange.state_corp_services import StateCorpExchangeService
|
||||
from apps.parsers.models import GenericParserRecord, ParserLoadLog
|
||||
from apps.registers.models import Register
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
@@ -20,7 +22,11 @@ from tests.apps.parsers.factories import (
|
||||
InspectionRecordFactory,
|
||||
ProcurementRecordFactory,
|
||||
)
|
||||
from tests.apps.registers.factories import OrganizationFactory
|
||||
from tests.apps.registers.factories import (
|
||||
OrganizationFactory,
|
||||
RegisterFactory,
|
||||
RegistryMembershipPeriodFactory,
|
||||
)
|
||||
|
||||
|
||||
def _b64url_decode(value: str) -> bytes:
|
||||
@@ -28,6 +34,25 @@ def _b64url_decode(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(normalized.encode("ascii"))
|
||||
|
||||
|
||||
def _decode_package_payload(package):
|
||||
with ZipFile(BytesIO(package.archive_bytes)) as archive:
|
||||
bin_names = [name for name in archive.namelist() if name.endswith(".bin")]
|
||||
assert len(bin_names) == 1
|
||||
bin_bytes = archive.read(bin_names[0])
|
||||
|
||||
header_size = struct.unpack(">I", bin_bytes[5:9])[0]
|
||||
header_end = 9 + header_size
|
||||
header = json.loads(bin_bytes[9:header_end].decode("utf-8"))
|
||||
encrypted_payload = bin_bytes[header_end:]
|
||||
raw_key = hashlib.sha256(TEST_STATE_CORP_TOKEN.encode("utf-8")).digest()
|
||||
compressed_payload = AESGCM(raw_key).decrypt(
|
||||
_b64url_decode(header["nonce"]),
|
||||
encrypted_payload,
|
||||
_b64url_decode(header["aad"]),
|
||||
)
|
||||
return json.loads(zlib.decompress(compressed_payload).decode("utf-8"))
|
||||
|
||||
|
||||
TEST_STATE_CORP_TOKEN = "state-corp-test-exchange-token" # noqa: S105
|
||||
|
||||
|
||||
@@ -39,6 +64,7 @@ class StateCorpExchangeServiceTest(TestCase):
|
||||
"""Verify package compatibility with state-corp receiver contract."""
|
||||
|
||||
def test_build_package_contains_expected_payload(self):
|
||||
registry = Register.objects.get(name="Реестр госкорпорации Росатом")
|
||||
organization = OrganizationFactory.create(
|
||||
mn_inn=7707083893,
|
||||
mn_ogrn=1027700132195,
|
||||
@@ -46,6 +72,12 @@ class StateCorpExchangeServiceTest(TestCase):
|
||||
in_kpp=770701001,
|
||||
mn_okpo="12345678",
|
||||
)
|
||||
RegistryMembershipPeriodFactory.create(
|
||||
registry=registry,
|
||||
organization=organization,
|
||||
started_at="2026-01-01",
|
||||
ended_at=None,
|
||||
)
|
||||
IndustrialProductRecordFactory.create(
|
||||
inn=str(organization.mn_inn),
|
||||
ogrn=str(organization.mn_ogrn),
|
||||
@@ -82,29 +114,100 @@ class StateCorpExchangeServiceTest(TestCase):
|
||||
max_price_amount="4500000.75",
|
||||
registry_organization=organization,
|
||||
)
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.ARBITRATION,
|
||||
load_batch=1,
|
||||
external_id="case-001",
|
||||
inn=str(organization.mn_inn),
|
||||
ogrn=str(organization.mn_ogrn),
|
||||
title="А40-1/2026",
|
||||
record_date="2026-03-25",
|
||||
status="in_progress",
|
||||
payload={
|
||||
"case_number": "А40-1/2026",
|
||||
"court_name": "АС города Москвы",
|
||||
"target": {"role": "ответчик"},
|
||||
},
|
||||
registry_organization=organization,
|
||||
)
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.FEDRESURS_BANKRUPTCY,
|
||||
load_batch=1,
|
||||
external_id="fedresurs-001",
|
||||
inn=str(organization.mn_inn),
|
||||
ogrn=str(organization.mn_ogrn),
|
||||
title="Сообщение о намерении",
|
||||
record_date="2026-03-26",
|
||||
status="published",
|
||||
url="https://fedresurs.ru/message/001",
|
||||
payload={
|
||||
"type": "Сообщение о намерении",
|
||||
"date": "2026-03-26",
|
||||
"case_number": "А40-555/2026",
|
||||
},
|
||||
registry_organization=organization,
|
||||
)
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.FAS_GOZ,
|
||||
load_batch=1,
|
||||
external_id="fas-goz-001",
|
||||
inn=str(organization.mn_inn),
|
||||
ogrn=str(organization.mn_ogrn),
|
||||
title="Уклонение от заключения контракта",
|
||||
record_date="2026-02-20",
|
||||
status="active",
|
||||
url="https://fas.gov.ru/register/001",
|
||||
payload={
|
||||
"Номер реестровой записи": "ГОЗ-001",
|
||||
"Полное наименование лица": organization.pn_name,
|
||||
"Дата вступления постановления": "2026-02-20",
|
||||
},
|
||||
registry_organization=organization,
|
||||
)
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.FSTEC,
|
||||
load_batch=1,
|
||||
external_id="fstec-001",
|
||||
inn=str(organization.mn_inn),
|
||||
ogrn=str(organization.mn_ogrn),
|
||||
title="Реестр лицензий ФСТЭК",
|
||||
record_date="2026-01-10",
|
||||
status="present",
|
||||
payload={
|
||||
"registry_name": "Реестр лицензий ФСТЭК",
|
||||
"entry_number": "77-001234",
|
||||
"issued_at": "2026-01-10",
|
||||
"expires_at": "2027-01-10",
|
||||
},
|
||||
registry_organization=organization,
|
||||
)
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.TRUDVSEM,
|
||||
load_batch=1,
|
||||
external_id="trudvsem-001",
|
||||
inn=str(organization.mn_inn),
|
||||
ogrn=str(organization.mn_ogrn),
|
||||
title="Инженер-испытатель",
|
||||
record_date="2026-04-01",
|
||||
amount="175000.00",
|
||||
status="open",
|
||||
url="https://trudvsem.ru/vacancy/001",
|
||||
payload={"vacancy_source": "trudvsem"},
|
||||
registry_organization=organization,
|
||||
)
|
||||
|
||||
package = StateCorpExchangeService.build_package()
|
||||
package = StateCorpExchangeService.build_package(actual_date="2026-03-15")
|
||||
self.assertEqual(package.payload_counts["organizations"], 1)
|
||||
self.assertEqual(package.payload_counts["industrial_products"], 1)
|
||||
self.assertEqual(package.payload_counts["prosecutor_checks"], 1)
|
||||
self.assertEqual(package.payload_counts["public_procurements"], 1)
|
||||
self.assertEqual(package.payload_counts["arbitration_cases"], 1)
|
||||
self.assertEqual(package.payload_counts["bankruptcy_procedures"], 1)
|
||||
self.assertEqual(package.payload_counts["defense_unreliable_suppliers"], 1)
|
||||
self.assertEqual(package.payload_counts["information_security_registries"], 1)
|
||||
self.assertEqual(package.payload_counts["labor_vacancies"], 1)
|
||||
|
||||
with ZipFile(BytesIO(package.archive_bytes)) as archive:
|
||||
bin_names = [name for name in archive.namelist() if name.endswith(".bin")]
|
||||
self.assertEqual(len(bin_names), 1)
|
||||
bin_bytes = archive.read(bin_names[0])
|
||||
|
||||
header_size = struct.unpack(">I", bin_bytes[5:9])[0]
|
||||
header_end = 9 + header_size
|
||||
header = json.loads(bin_bytes[9:header_end].decode("utf-8"))
|
||||
encrypted_payload = bin_bytes[header_end:]
|
||||
raw_key = hashlib.sha256(TEST_STATE_CORP_TOKEN.encode("utf-8")).digest()
|
||||
compressed_payload = AESGCM(raw_key).decrypt(
|
||||
_b64url_decode(header["nonce"]),
|
||||
encrypted_payload,
|
||||
_b64url_decode(header["aad"]),
|
||||
)
|
||||
payload = json.loads(zlib.decompress(compressed_payload).decode("utf-8"))
|
||||
payload = _decode_package_payload(package)
|
||||
|
||||
self.assertEqual(payload["format"], StateCorpExchangeService.PAYLOAD_FORMAT)
|
||||
self.assertEqual(payload["manifest"]["source_system"], "mostovik")
|
||||
@@ -121,7 +224,111 @@ class StateCorpExchangeServiceTest(TestCase):
|
||||
payload["data"]["public_procurements"][0]["purchase_number"],
|
||||
"purchase-001",
|
||||
)
|
||||
self.assertEqual(payload["data"]["arbitration_cases"], [])
|
||||
self.assertEqual(
|
||||
payload["data"]["arbitration_cases"][0]["case_number"],
|
||||
"А40-1/2026",
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["data"]["bankruptcy_procedures"][0]["case_number"],
|
||||
"А40-555/2026",
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["data"]["defense_unreliable_suppliers"][0]["registry_number"],
|
||||
"ГОЗ-001",
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["data"]["information_security_registries"][0]["entry_number"],
|
||||
"77-001234",
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["data"]["labor_vacancies"][0]["title"],
|
||||
"Инженер-испытатель",
|
||||
)
|
||||
|
||||
def test_build_package_exports_only_active_rosatom_roscosmos_registry_members(self):
|
||||
target_registry = Register.objects.get(name="Реестр госкорпорации Роскосмос")
|
||||
non_target_registry = RegisterFactory.create()
|
||||
|
||||
target = OrganizationFactory.create(
|
||||
mn_inn=7707000001,
|
||||
mn_ogrn=1027700000001,
|
||||
pn_name="АО Целевая",
|
||||
)
|
||||
non_target = OrganizationFactory.create(
|
||||
mn_inn=7707000002,
|
||||
mn_ogrn=1027700000002,
|
||||
pn_name="АО Не экспортируется",
|
||||
)
|
||||
inactive_target = OrganizationFactory.create(
|
||||
mn_inn=7707000003,
|
||||
mn_ogrn=1027700000003,
|
||||
pn_name="АО Бывшая",
|
||||
)
|
||||
RegistryMembershipPeriodFactory.create(
|
||||
registry=target_registry,
|
||||
organization=target,
|
||||
started_at="2026-01-01",
|
||||
ended_at=None,
|
||||
)
|
||||
RegistryMembershipPeriodFactory.create(
|
||||
registry=non_target_registry,
|
||||
organization=non_target,
|
||||
started_at="2026-01-01",
|
||||
ended_at=None,
|
||||
)
|
||||
RegistryMembershipPeriodFactory.create(
|
||||
registry=target_registry,
|
||||
organization=inactive_target,
|
||||
started_at="2026-01-01",
|
||||
ended_at="2026-03-01",
|
||||
)
|
||||
IndustrialProductRecordFactory.create(
|
||||
inn=str(target.mn_inn),
|
||||
registry_number="target-product",
|
||||
registry_organization=target,
|
||||
)
|
||||
IndustrialProductRecordFactory.create(
|
||||
inn=str(non_target.mn_inn),
|
||||
registry_number="non-target-product",
|
||||
registry_organization=non_target,
|
||||
)
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.TRUDVSEM,
|
||||
load_batch=1,
|
||||
external_id="target-vacancy",
|
||||
inn=str(target.mn_inn),
|
||||
title="Целевая вакансия",
|
||||
record_date="2026-03-10",
|
||||
registry_organization=target,
|
||||
)
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.TRUDVSEM,
|
||||
load_batch=1,
|
||||
external_id="non-target-vacancy",
|
||||
inn=str(non_target.mn_inn),
|
||||
title="Лишняя вакансия",
|
||||
record_date="2026-03-10",
|
||||
registry_organization=non_target,
|
||||
)
|
||||
|
||||
package = StateCorpExchangeService.build_package(actual_date="2026-03-15")
|
||||
payload = _decode_package_payload(package)
|
||||
|
||||
self.assertEqual(
|
||||
[item["inn"] for item in payload["data"]["organizations"]],
|
||||
[str(target.mn_inn)],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
item["registry_number"]
|
||||
for item in payload["data"]["industrial_products"]
|
||||
],
|
||||
["target-product"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["external_id"] for item in payload["data"]["labor_vacancies"]],
|
||||
["target-vacancy"],
|
||||
)
|
||||
|
||||
@patch("apps.exchange.state_corp_services.requests.post")
|
||||
def test_send_package_posts_multipart_archive(self, post_mock):
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
from apps.core.models import BackgroundJob, JobStatus
|
||||
from apps.parsers.models import GenericParserRecord, ParserLoadLog
|
||||
from apps.parsers.source_cards import (
|
||||
SOURCE_CARD_DEFINITIONS,
|
||||
SourceCardDefinition,
|
||||
SourceCardService,
|
||||
SourceItemDefinition,
|
||||
@@ -18,6 +19,36 @@ from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
class SourceCardServiceUnitTest(SimpleTestCase):
|
||||
def test_list_cards_exposes_all_frontend_category_slugs_in_menu_order(self):
|
||||
self.assertEqual(
|
||||
[card.slug for card in SOURCE_CARD_DEFINITIONS],
|
||||
[
|
||||
"financial-indicators",
|
||||
"public-procurements",
|
||||
"manufacturers-and-products",
|
||||
"planned-inspections",
|
||||
"bankruptcy-procedures",
|
||||
"defense-unreliable-suppliers",
|
||||
"arbitration-cases",
|
||||
"information-security-registries",
|
||||
"labor-vacancies",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[card.title for card in SOURCE_CARD_DEFINITIONS],
|
||||
[
|
||||
"Финансово-экономические показатели",
|
||||
"Государственные закупки по 44-ФЗ и 223-ФЗ",
|
||||
"Производители и продукция России",
|
||||
"Плановые проверки Генпрокуратуры России",
|
||||
"Сведения о процедурах банкротства",
|
||||
"Недобросовестные поставщики ГОЗ",
|
||||
"Арбитражные дела",
|
||||
"Реестры по информационной безопасности",
|
||||
"Вакансии Работа России",
|
||||
],
|
||||
)
|
||||
|
||||
def test_get_definition_raises_for_unknown_slug(self):
|
||||
with self.assertRaises(Http404):
|
||||
SourceCardService.get_definition("missing-card")
|
||||
@@ -156,6 +187,40 @@ class SourceCardServiceUnitTest(SimpleTestCase):
|
||||
},
|
||||
)
|
||||
|
||||
@patch(
|
||||
"apps.parsers.source_cards.SourceCardService._enqueue_task",
|
||||
side_effect=[
|
||||
{
|
||||
"task_id": "task-unfair",
|
||||
"task_name": "apps.parsers.tasks.parse_unfair_suppliers",
|
||||
},
|
||||
{
|
||||
"task_id": "task-goz",
|
||||
"task_name": "apps.parsers.tasks.parse_fas_goz_evasion",
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_refresh_card_for_defense_unreliable_suppliers_enqueues_sources(
|
||||
self, enqueue_mock
|
||||
):
|
||||
result = SourceCardService.refresh_card(
|
||||
slug="defense-unreliable-suppliers",
|
||||
requested_by_id=10,
|
||||
)
|
||||
|
||||
self.assertEqual(result["source_card"], "defense-unreliable-suppliers")
|
||||
self.assertEqual(
|
||||
[item["task_id"] for item in result["tasks"]],
|
||||
["task-unfair", "task-goz"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[call.kwargs["meta"]["source"] for call in enqueue_mock.call_args_list],
|
||||
[
|
||||
ParserLoadLog.Source.UNFAIR_SUPPLIERS,
|
||||
ParserLoadLog.Source.FAS_GOZ,
|
||||
],
|
||||
)
|
||||
|
||||
def test_launch_refresh_raises_for_unsupported_card(self):
|
||||
definition = SourceCardDefinition(
|
||||
slug="custom-source",
|
||||
@@ -297,6 +362,42 @@ class SourceCardServiceUnitTest(SimpleTestCase):
|
||||
|
||||
@override_settings(PARSER_STALE_LOAD_MAX_AGE_MINUTES=90)
|
||||
class SourceCardServiceDatabaseTest(TestCase):
|
||||
def test_defense_unreliable_suppliers_counts_unique_generic_organizations(self):
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.UNFAIR_SUPPLIERS,
|
||||
load_batch=1,
|
||||
external_id="unfair-1",
|
||||
inn="7701234567",
|
||||
title="Недобросовестный поставщик",
|
||||
payload={"number": "unfair-1"},
|
||||
)
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.FAS_GOZ,
|
||||
load_batch=1,
|
||||
external_id="goz-1",
|
||||
inn="7701234567",
|
||||
title="Уклонение от ГОЗ",
|
||||
payload={"number": "goz-1"},
|
||||
)
|
||||
ParserLoadLog.objects.create(
|
||||
source=ParserLoadLog.Source.UNFAIR_SUPPLIERS,
|
||||
batch_id=1,
|
||||
records_count=1,
|
||||
status=ParserLoadLog.Status.SUCCESS,
|
||||
)
|
||||
ParserLoadLog.objects.create(
|
||||
source=ParserLoadLog.Source.FAS_GOZ,
|
||||
batch_id=1,
|
||||
records_count=1,
|
||||
status=ParserLoadLog.Status.SUCCESS,
|
||||
)
|
||||
|
||||
card = SourceCardService.get_card("defense-unreliable-suppliers")
|
||||
|
||||
self.assertEqual(card["status"], "success")
|
||||
self.assertEqual(card["records_count"], 2)
|
||||
self.assertEqual(card["organizations_count"], 1)
|
||||
|
||||
def test_public_procurements_counts_generic_eis_sources(self):
|
||||
GenericParserRecord.objects.create(
|
||||
source=ParserLoadLog.Source.PROCUREMENTS_44FZ,
|
||||
|
||||
@@ -249,7 +249,9 @@ class GenericSourceFetchTestCase(TestCase):
|
||||
)
|
||||
RegistryMembershipPeriodFactory(organization=organization, ended_at=None)
|
||||
RegistryMembershipPeriodFactory(
|
||||
organization=inactive, ended_at=date(2026, 5, 7)
|
||||
organization=inactive,
|
||||
started_at=date(2026, 5, 1),
|
||||
ended_at=date(2026, 5, 7),
|
||||
)
|
||||
captured_inns = []
|
||||
|
||||
@@ -317,7 +319,9 @@ class GenericSourceFetchTestCase(TestCase):
|
||||
)
|
||||
RegistryMembershipPeriodFactory(organization=organization, ended_at=None)
|
||||
RegistryMembershipPeriodFactory(
|
||||
organization=inactive, ended_at=date(2026, 5, 7)
|
||||
organization=inactive,
|
||||
started_at=date(2026, 5, 1),
|
||||
ended_at=date(2026, 5, 7),
|
||||
)
|
||||
|
||||
class _CheckoClient:
|
||||
@@ -448,7 +452,9 @@ class GenericSourceFetchTestCase(TestCase):
|
||||
)
|
||||
RegistryMembershipPeriodFactory(organization=organization, ended_at=None)
|
||||
RegistryMembershipPeriodFactory(
|
||||
organization=inactive, ended_at=date(2026, 5, 7)
|
||||
organization=inactive,
|
||||
started_at=date(2026, 5, 1),
|
||||
ended_at=date(2026, 5, 7),
|
||||
)
|
||||
|
||||
class _CheckoClient:
|
||||
@@ -523,7 +529,9 @@ class GenericSourceFetchTestCase(TestCase):
|
||||
)
|
||||
RegistryMembershipPeriodFactory(organization=organization, ended_at=None)
|
||||
RegistryMembershipPeriodFactory(
|
||||
organization=inactive, ended_at=date(2026, 5, 7)
|
||||
organization=inactive,
|
||||
started_at=date(2026, 5, 1),
|
||||
ended_at=date(2026, 5, 7),
|
||||
)
|
||||
|
||||
class _CheckoClient:
|
||||
@@ -2021,7 +2029,9 @@ class ParseVacanciesTaskTestCase(TestCase):
|
||||
RegistryMembershipPeriodFactory(organization=active_first, ended_at=None)
|
||||
RegistryMembershipPeriodFactory(organization=active_second, ended_at=None)
|
||||
RegistryMembershipPeriodFactory(
|
||||
organization=inactive, ended_at=date(2026, 5, 7)
|
||||
organization=inactive,
|
||||
started_at=date(2026, 5, 1),
|
||||
ended_at=date(2026, 5, 7),
|
||||
)
|
||||
RegistryMembershipPeriodFactory(organization=active_first, ended_at=None)
|
||||
captured_fetches = []
|
||||
|
||||
@@ -9,7 +9,6 @@ from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from apps.backups.models import BackupExportJob
|
||||
from apps.core.models import BackgroundJob
|
||||
from apps.exchange.models import ExchangeConnection
|
||||
from apps.parsers.models import (
|
||||
@@ -708,19 +707,29 @@ class BackupsApiInventoryE2ETest(AuthenticatedApiMixin, APITestCase):
|
||||
def setUp(self):
|
||||
self.admin = UserFactory.create_superuser()
|
||||
|
||||
@patch("apps.backups.services.BackupExportJobService._enqueue_backup_task")
|
||||
def test_backup_export_endpoint(self, enqueue_mock):
|
||||
@patch("apps.backups.views.StateCorpExchangeService.build_package")
|
||||
def test_backup_export_endpoint(self, build_package_mock):
|
||||
self.authenticate(self.admin)
|
||||
today = timezone.localdate()
|
||||
build_package_mock.return_value = SimpleNamespace(
|
||||
package_id="package-1",
|
||||
archive_name="state_corp_exchange.zip",
|
||||
bin_name="state_corp_exchange.bin",
|
||||
archive_bytes=b"zip-bytes",
|
||||
payload_counts={"organizations": 1},
|
||||
produced_at="2026-05-12T10:00:00+00:00",
|
||||
)
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
response = self.client.post(
|
||||
reverse("api_v1:backups:export"),
|
||||
{"actual_date": today.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("api_v1:backups:export"),
|
||||
{"actual_date": today.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertEqual(response.data["status"], "started")
|
||||
self.assertTrue(BackupExportJob.objects.filter(actual_date=today).exists())
|
||||
enqueue_mock.assert_called_once()
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.content, b"zip-bytes")
|
||||
self.assertIn(
|
||||
'attachment; filename="state_corp_exchange.zip"',
|
||||
response["Content-Disposition"],
|
||||
)
|
||||
build_package_mock.assert_called_once_with(actual_date=today)
|
||||
|
||||
Reference in New Issue
Block a user