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
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:
149
tests/apps/parsers/test_registry_http.py
Normal file
149
tests/apps/parsers/test_registry_http.py
Normal 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")])
|
||||
Reference in New Issue
Block a user