mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:47:03 +01:00
The last six over-cap files split into eleven new modules, in two passes since the first cut left five still over. python_pkg has no violations left at all, down from 50 when this effort started. Each split carries the whole preamble including the module's pytest fixtures - started, toolkit and session are taken as parameters and resolve lexically, so a hand-written import block would have missed them. ruff then pruned 82 unused imports. Pass count is 277 before and after, checked after both passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
251 lines
8.4 KiB
Python
251 lines
8.4 KiB
Python
"""Tests for the HTTP layer.
|
|
|
|
No socket is opened: ``requests.Session`` is replaced with a mock whose
|
|
``iter_content`` yields real bytes, so the md5 verification path computes a
|
|
genuine digest rather than comparing two mocks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from python_pkg.wsg_grabber import net
|
|
from python_pkg.wsg_grabber.constants import API_HEADERS, MEDIA_HEADERS
|
|
from python_pkg.wsg_grabber.models import Outcome
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_BODY = b"fake webm bytes, but real bytes" * 40
|
|
|
|
|
|
def _response(
|
|
status: int = 200,
|
|
*,
|
|
body: bytes = b"",
|
|
headers: dict[str, str] | None = None,
|
|
text: str = "",
|
|
) -> MagicMock:
|
|
"""Build a stand-in for a requests Response.
|
|
|
|
Args:
|
|
status: HTTP status code.
|
|
body: Payload streamed by ``iter_content``.
|
|
headers: Response headers.
|
|
text: Body for JSON decoding.
|
|
|
|
Returns:
|
|
MagicMock: Response double.
|
|
"""
|
|
response = MagicMock()
|
|
response.status_code = status
|
|
response.headers = headers or {}
|
|
response.text = text
|
|
response.content = text.encode("utf-8")
|
|
response.iter_content.return_value = [body] if body else []
|
|
return response
|
|
|
|
|
|
def _transfer(tmp_path: Path, *, md5: str | None = None, size: int = 0) -> net.Transfer:
|
|
"""Build a Transfer pointing at a sandboxed part file.
|
|
|
|
Args:
|
|
tmp_path: Per-test temporary directory.
|
|
md5: Expected digest; defaults to the real digest of ``_BODY``.
|
|
size: Expected file size.
|
|
|
|
Returns:
|
|
net.Transfer: Transfer description.
|
|
"""
|
|
return net.Transfer(
|
|
url="https://i.4cdn.org/wsg/1.webm",
|
|
part_path=tmp_path / "1.webm.part",
|
|
expected_md5=md5 if md5 is not None else net.digest_of(_BODY),
|
|
expected_size=size,
|
|
should_stop=lambda: False,
|
|
)
|
|
|
|
|
|
def test_digest_matches_the_field_the_api_publishes() -> None:
|
|
"""Verified against a real /wsg/ file during design."""
|
|
assert net.digest_of(b"") == "1B2M2Y8AsgTpgAmY7PhCfg=="
|
|
assert len(net.digest_of(_BODY)) == 24
|
|
|
|
|
|
def test_build_session_carries_the_api_headers() -> None:
|
|
session = net.build_session()
|
|
try:
|
|
assert session.headers["User-Agent"] == API_HEADERS["User-Agent"]
|
|
assert session.headers["Referer"] == API_HEADERS["Referer"]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_get_json_decodes_a_fresh_payload() -> None:
|
|
session = MagicMock()
|
|
session.get.return_value = _response(
|
|
200,
|
|
text='{"posts": [1]}',
|
|
headers={"Last-Modified": "Mon, 27 Jul 2026 08:00:00 GMT"},
|
|
)
|
|
result = net.get_json(session, "https://a.4cdn.org/wsg/threads.json")
|
|
assert result.payload == {"posts": [1]}
|
|
assert result.last_modified == "Mon, 27 Jul 2026 08:00:00 GMT"
|
|
assert not result.not_modified
|
|
assert session.get.call_args.kwargs["headers"] == {}
|
|
|
|
|
|
def test_get_json_sends_if_modified_since_and_honours_304() -> None:
|
|
"""An unchanged thread must cost a 304, not a re-parse."""
|
|
session = MagicMock()
|
|
session.get.return_value = _response(304)
|
|
stamp = "Mon, 27 Jul 2026 08:00:00 GMT"
|
|
result = net.get_json(session, "https://a.4cdn.org/wsg/thread/1.json", stamp)
|
|
assert result.not_modified
|
|
assert result.payload is None
|
|
assert result.last_modified == stamp
|
|
assert session.get.call_args.kwargs["headers"] == {"If-Modified-Since": stamp}
|
|
|
|
|
|
@pytest.mark.parametrize("status", [404, 410])
|
|
def test_get_json_reports_a_vanished_thread(status: int) -> None:
|
|
session = MagicMock()
|
|
session.get.return_value = _response(status)
|
|
result = net.get_json(session, "https://a.4cdn.org/wsg/thread/1.json")
|
|
assert result.not_found
|
|
assert result.payload is None
|
|
|
|
|
|
def test_get_json_raises_on_other_failures() -> None:
|
|
session = MagicMock()
|
|
response = _response(500)
|
|
response.raise_for_status.side_effect = requests.HTTPError("boom")
|
|
session.get.return_value = response
|
|
with pytest.raises(requests.HTTPError):
|
|
net.get_json(session, "https://a.4cdn.org/wsg/threads.json")
|
|
|
|
|
|
def test_download_writes_and_verifies_the_file(tmp_path: Path) -> None:
|
|
session = MagicMock()
|
|
session.get.return_value = _response(200, body=_BODY)
|
|
transfer = _transfer(tmp_path)
|
|
result = net.download(session, transfer)
|
|
assert result.outcome is Outcome.COMPLETED
|
|
assert result.bytes_done == len(_BODY)
|
|
assert transfer.part_path.read_bytes() == _BODY
|
|
assert (
|
|
session.get.call_args.kwargs["headers"]["Referer"] == MEDIA_HEADERS["Referer"]
|
|
)
|
|
assert "Range" not in session.get.call_args.kwargs["headers"]
|
|
|
|
|
|
def test_download_reports_progress(tmp_path: Path) -> None:
|
|
session = MagicMock()
|
|
session.get.return_value = _response(200, body=_BODY)
|
|
seen: list[int] = []
|
|
transfer = net.Transfer(
|
|
url="https://i.4cdn.org/wsg/1.webm",
|
|
part_path=tmp_path / "1.webm.part",
|
|
expected_md5=net.digest_of(_BODY),
|
|
expected_size=0,
|
|
should_stop=lambda: False,
|
|
on_progress=seen.append,
|
|
)
|
|
net.download(session, transfer)
|
|
assert seen == [len(_BODY)]
|
|
|
|
|
|
def test_download_rejects_a_body_whose_digest_disagrees(tmp_path: Path) -> None:
|
|
"""A mangled transfer must never reach the reviewer."""
|
|
session = MagicMock()
|
|
session.get.return_value = _response(200, body=_BODY)
|
|
transfer = _transfer(tmp_path, md5="A" * 24)
|
|
result = net.download(session, transfer)
|
|
assert result.outcome is Outcome.CHECKSUM_MISMATCH
|
|
assert not transfer.part_path.exists()
|
|
|
|
|
|
@pytest.mark.parametrize("status", [404, 410])
|
|
def test_download_marks_a_missing_file_not_found(tmp_path: Path, status: int) -> None:
|
|
session = MagicMock()
|
|
session.get.return_value = _response(status)
|
|
result = net.download(session, _transfer(tmp_path))
|
|
assert result.outcome is Outcome.NOT_FOUND
|
|
|
|
|
|
def test_download_reads_retry_after_on_429(tmp_path: Path) -> None:
|
|
session = MagicMock()
|
|
session.get.return_value = _response(429, headers={"Retry-After": "45"})
|
|
result = net.download(session, _transfer(tmp_path))
|
|
assert result.outcome is Outcome.TRANSIENT
|
|
assert result.retry_after == pytest.approx(45.0)
|
|
|
|
|
|
def test_download_treats_server_errors_as_transient(tmp_path: Path) -> None:
|
|
session = MagicMock()
|
|
session.get.return_value = _response(503)
|
|
result = net.download(session, _transfer(tmp_path))
|
|
assert result.outcome is Outcome.TRANSIENT
|
|
assert result.retry_after is None
|
|
|
|
|
|
def test_download_treats_an_unexpected_status_as_transient(tmp_path: Path) -> None:
|
|
session = MagicMock()
|
|
session.get.return_value = _response(302)
|
|
result = net.download(session, _transfer(tmp_path))
|
|
assert result.outcome is Outcome.TRANSIENT
|
|
assert result.message is not None
|
|
assert "302" in result.message
|
|
|
|
|
|
def test_download_turns_a_network_error_into_a_transient_result(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
session = MagicMock()
|
|
session.get.side_effect = requests.ConnectionError("no route")
|
|
result = net.download(session, _transfer(tmp_path))
|
|
assert result.outcome is Outcome.TRANSIENT
|
|
assert result.message == "no route"
|
|
|
|
|
|
def test_download_resumes_a_partial_file(tmp_path: Path) -> None:
|
|
part = tmp_path / "1.webm.part"
|
|
part.write_bytes(_BODY[:100])
|
|
session = MagicMock()
|
|
session.get.return_value = _response(206, body=_BODY[100:])
|
|
transfer = net.Transfer(
|
|
url="https://i.4cdn.org/wsg/1.webm",
|
|
part_path=part,
|
|
expected_md5=net.digest_of(_BODY),
|
|
expected_size=len(_BODY),
|
|
should_stop=lambda: False,
|
|
)
|
|
result = net.download(session, transfer)
|
|
assert session.get.call_args.kwargs["headers"]["Range"] == "bytes=100-"
|
|
assert result.outcome is Outcome.COMPLETED
|
|
assert part.read_bytes() == _BODY
|
|
|
|
|
|
def test_a_server_ignoring_range_restarts_rather_than_appending(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Replying 200 to a Range request means the whole file is coming again."""
|
|
part = tmp_path / "1.webm.part"
|
|
part.write_bytes(_BODY[:100])
|
|
session = MagicMock()
|
|
session.get.return_value = _response(200, body=_BODY)
|
|
transfer = net.Transfer(
|
|
url="https://i.4cdn.org/wsg/1.webm",
|
|
part_path=part,
|
|
expected_md5=net.digest_of(_BODY),
|
|
expected_size=len(_BODY),
|
|
should_stop=lambda: False,
|
|
)
|
|
result = net.download(session, transfer)
|
|
assert result.outcome is Outcome.COMPLETED
|
|
assert part.read_bytes() == _BODY
|