feat: обновления парсеров, тестов и миграций
Some checks failed
CI/CD Pipeline / Run Tests (push) Failing after 37s
CI/CD Pipeline / Code Quality Checks (push) Failing after 43s
CI/CD Pipeline / Build & Push Images (push) Has been skipped
CI/CD Pipeline / Deploy (dev) (push) Has been skipped
CI/CD Pipeline / Deploy (prod) (push) Has been skipped
CI/CD Pipeline / Code Quality Checks (pull_request) Failing after 0s
CI/CD Pipeline / Run Tests (pull_request) Failing after 0s
CI/CD Pipeline / Build & Push Images (pull_request) Has been skipped
CI/CD Pipeline / Deploy (dev) (pull_request) Has been skipped
CI/CD Pipeline / Deploy (prod) (pull_request) Has been skipped

- Обновлены клиенты парсеров (checko, fns, minpromtorg, proverki, zakupki)
- Добавлены новые миграции для моделей
- Расширено покрытие тестами
- Обновлены конфигурации и настройки проекта
- Добавлены утилиты для тестирования

Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
2026-02-10 10:17:47 +01:00
parent 975d019ba5
commit ee95628a0a
59 changed files with 7292 additions and 2876 deletions

View File

@@ -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 = (
"<?xml version='1.0' encoding='utf-8'?>"
"<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'>"
"<soap:Body>"
"<ns2:getDocsByOrgRegionResponse xmlns:ns2='http://zakupki.gov.ru/fz44/get-docs-ip/ws'>"
"<dataInfo><archiveUrl>%s</archiveUrl></dataInfo>"
"</ns2:getDocsByOrgRegionResponse>"
"</soap:Body>"
"</soap:Envelope>"
) % url
return xml.encode("utf-8")
def _soap_response_with_error(code: str, message: str) -> bytes:
xml = (
"<?xml version='1.0' encoding='utf-8'?>"
"<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'>"
"<soap:Body>"
"<errorInfo><code>%s</code><message>%s</message></errorInfo>"
"</soap:Body>"
"</soap:Envelope>"
) % (code, message)
return xml.encode("utf-8")
def _soap_response_with_fault(text: str) -> bytes:
xml = (
"<?xml version='1.0' encoding='utf-8'?>"
"<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'>"
"<soap:Body>"
"<soap:Fault>%s</soap:Fault>"
"</soap:Body>"
"</soap:Envelope>"
) % 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"""<?xml version="1.0" encoding="UTF-8"?>
<export>
<notification>
<purchaseNumber>0123456789012345678</purchaseNumber>
<purchaseObjectInfo>Test procurement</purchaseObjectInfo>
<customer>
<INN>1234567890</INN>
<KPP>123456789</KPP>
<OGRN>1234567890123</OGRN>
<fullName>Test Organization</fullName>
</customer>
<lot>
<maxPrice>1000000</maxPrice>
<currency>
<code>RUB</code>
</currency>
</lot>
<placingWay>
<name>Electronic auction</name>
</placingWay>
<publishDate>2025-01-15</publishDate>
<endDate>2025-02-15</endDate>
<state>Published</state>
</notification>
</export>
"""
self.empty_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
<export></export>
"""
self.valid_xml, self.rows = build_zakupki_xml(count=2)
self.empty_xml = b"<?xml version='1.0' encoding='UTF-8'?><export></export>"
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"""<?xml version="1.0" encoding="UTF-8"?>
<export xmlns="http://zakupki.gov.ru/export">
<notification>
<purchaseNumber>9876543210123456789</purchaseNumber>
<customer>
<INN>9876543210</INN>
<fullName>NS Organization</fullName>
</customer>
</notification>
</export>
"""
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 = """<?xml version="1.0" encoding="windows-1251"?>
<export>
<notification>
<purchaseNumber>1111111111111111111</purchaseNumber>
<customer>
<INN>1111111111</INN>
<fullName>Тестовая Организация</fullName>
</customer>
</notification>
</export>
""".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"""<?xml version="1.0" encoding="UTF-8"?>
<export>
<notification>
<purchaseNumber>1234567890123456789</purchaseNumber>
<customer>
<INN>1234567890</INN>
<fullName>ZIP Test Org</fullName>
</customer>
</notification>
</export>
"""
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"""<?xml version="1.0"?><export>
<notification>
<purchaseNumber>1111111111111111111</purchaseNumber>
<customer><INN>1111111111</INN><fullName>Org1</fullName></customer>
</notification>
</export>"""
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"""<?xml version="1.0"?><export>
<notification>
<purchaseNumber>2222222222222222222</purchaseNumber>
<customer><INN>2222222222</INN><fullName>Org2</fullName></customer>
</notification>
</export>"""
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 = "<?xml version='1.0'?><root>Test\x00\x01\x02</root>"
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 = "<a>\x01\x02</a>"
sanitized = client._sanitize_xml(xml)
self.assertEqual(sanitized, "<a></a>")
def test_sanitize_escapes_ampersands(self):
"""Экранирует неэкранированные амперсанды."""
dirty_xml = "<root>Test & Company</root>"
clean_xml = self.client._sanitize_xml(dirty_xml)
self.assertIn("&amp;", clean_xml)
client = ZakupkiClient()
xml = "<a>Tom & Jerry</a>"
sanitized = client._sanitize_xml(xml)
self.assertIn("Tom &amp; Jerry", sanitized)
def test_sanitize_keeps_valid_entities(self):
"""Сохраняет валидные XML сущности."""
valid_xml = "<root>&amp; &lt; &gt; &quot;</root>"
clean_xml = self.client._sanitize_xml(valid_xml)
client = ZakupkiClient()
xml = "<a>Tom &amp; Jerry</a>"
sanitized = client._sanitize_xml(xml)
self.assertEqual(sanitized, xml)
self.assertIn("&amp;", clean_xml)
self.assertIn("&lt;", clean_xml)
self.assertIn("&gt;", clean_xml)
self.assertIn("&quot;", 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"<xml")
def test_parse_soap_response_without_archive(self):
client = ZakupkiClient(token="token")
xml = (
"<?xml version='1.0' encoding='utf-8'?>"
"<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'>"
"<soap:Body><dataInfo></dataInfo></soap:Body></soap:Envelope>"
)
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"<?xml version='1.0' encoding='utf-8'?>"
b"<soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'>"
b"<soap:Body><dataInfo></dataInfo></soap:Body></soap:Envelope>"
),
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("<exactDate>2024-01-02</exactDate>", with_day)
with_month = client._build_soap_request_by_region(
region_code="77", year=2024, month=3
)
self.assertIn("<exactDate>2024-03-01</exactDate>", with_month)
with_year = client._build_soap_request_by_region(region_code="77", year=2024)
self.assertIn("<exactDate>2024-01-01</exactDate>", 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 = (
"<?xml version='1.0' encoding='windows-1251'?>"
f"<ns:export xmlns:ns='{ns}'>"
"<ns:notification>"
"<ns:purchaseNumber>123</ns:purchaseNumber>"
"<ns:purchaseName>Test</ns:purchaseName>"
"</ns:notification>"
"</ns:export>"
)
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 = (
"<?xml version='1.0' encoding='utf-8'?>"
"<export>"
"<record purchaseNumber='321' purchaseName='AttrName' customerName='C'/>"
"</export>"
)
client = ZakupkiClient()
records = client._parse_xml_content(xml.encode("utf-8"))
self.assertEqual(len(records), 1)