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
150 lines
4.6 KiB
Python
150 lines
4.6 KiB
Python
"""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")])
|