feat(registries): add SME and budget imports and fix source API workflows
All checks were successful
Mostovik Backend CI/CD / Tests and lint (push) Successful in 9m37s
Mostovik Backend CI/CD / Build linux/amd64 release images (push) Successful in 4m18s
Mostovik Backend CI/CD / Deploy and verify internal main (push) Has been skipped
Mostovik Backend CI/CD / Deploy customer main (push) Has been skipped
Mostovik Backend CI/CD / Deploy dev (push) Successful in 1m48s

This commit is contained in:
Aleksandr Meshchryakov
2026-09-13 23:13:47 +02:00
parent c863563851
commit 49cbfd265c
50 changed files with 5021 additions and 452 deletions

View File

@@ -0,0 +1,149 @@
"""Transport regression cases observed while downloading the full FNS snapshot."""
from contextlib import contextmanager
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import requests
from apps.parsers.registry_http import download_registry_archive
from apps.parsers.registry_snapshots import SnapshotValidationError
URL = "https://file.nalog.ru/archive.zip"
HOSTS = frozenset({"file.nalog.ru"})
def _response(
chunks, *, status=206, content_range="bytes 0-5/6", etag='"v1"', length=None
):
headers = {}
if content_range is not None:
headers["Content-Range"] = content_range
if etag is not None:
headers["ETag"] = etag
if length is not None:
headers["Content-Length"] = str(length)
def content(**_):
for chunk in chunks:
if isinstance(chunk, Exception):
raise chunk
yield chunk
return SimpleNamespace(status_code=status, headers=headers, iter_content=content)
def _download(responses, **kwargs):
calls = []
responses = iter(responses)
@contextmanager
def response(_session, url, *, hosts, headers):
assert url == URL
assert hosts == HOSTS
calls.append(headers)
yield next(responses)
handle = BytesIO()
with patch("apps.parsers.registry_http.registry_response", side_effect=response):
result = download_registry_archive(
object(), URL, handle, hosts=HOSTS, maximum=1024, range_bytes=6, **kwargs
)
return handle.getvalue(), result, calls
def test_interrupted_body_resumes_only_missing_bytes_with_validator():
content, count, calls = _download(
[
_response([b"ab", requests.exceptions.ChunkedEncodingError("interrupted")]),
_response([b"cdef"], content_range="bytes 2-5/6"),
]
)
assert content == b"abcdef"
assert count == 6
assert calls[1]["Range"] == "bytes=2-7"
assert calls[1]["If-Range"] == '"v1"'
@pytest.mark.parametrize(
("next_response", "reason"),
[
(
_response([b"cdef"], content_range="bytes 2-5/6", etag='"v2"'),
"source_changed_during_download",
),
(
_response([b"cdefgh"], content_range="bytes 2-7/8"),
"source_changed_during_download",
),
(_response([b"abcdef"], status=200, length=6), "source_range_not_honored"),
(
_response([b"cdef"], content_range="bytes 0-3/6"),
"unexpected_source_content_range",
),
],
)
def test_resume_rejects_changed_or_unproven_entity(next_response, reason):
with pytest.raises(SnapshotValidationError, match=reason):
_download(
[
_response(
[b"ab", requests.exceptions.ChunkedEncodingError("interrupted")]
),
next_response,
]
)
def test_no_range_server_restarts_entire_body_after_interruption():
content, count, calls = _download(
[
_response(
[b"ab", requests.exceptions.ChunkedEncodingError("interrupted")],
status=200,
length=6,
),
_response([b"abcdef"], status=200, length=6),
]
)
assert content == b"abcdef"
assert count == 6
assert calls[1]["Range"] == "bytes=0-5"
assert "If-Range" not in calls[1]
def test_range_without_validator_is_rejected():
with pytest.raises(
SnapshotValidationError, match="source_resume_validator_missing"
):
_download([_response([b"abcdef"], etag=None)])
def test_last_modified_is_used_when_strong_etag_is_unavailable():
first = _response(
[b"ab", requests.exceptions.ChunkedEncodingError("interrupted")],
etag='W/"weak"',
)
second = _response([b"cdef"], content_range="bytes 2-5/6", etag='W/"weak"')
for response in (first, second):
response.headers["Last-Modified"] = "Sat, 15 Aug 2026 09:46:30 GMT"
content, _, calls = _download([first, second])
assert content == b"abcdef"
assert calls[1]["If-Range"] == "Sat, 15 Aug 2026 09:46:30 GMT"
def test_body_retry_count_is_bounded():
with pytest.raises(requests.exceptions.ChunkedEncodingError):
_download(
[
_response([requests.exceptions.ChunkedEncodingError("interrupted")])
for _ in range(2)
],
max_retries=1,
)
def test_oversized_archive_is_rejected_before_reading_body():
with pytest.raises(SnapshotValidationError, match="source_response_too_large"):
_download([_response([], content_range="bytes 0-5/2048")])