testsAndMisc/python_pkg/wsg_grabber/tests/test_net.py
Krzysztof kuhy Rudnicki 89ed9bc95d
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Python tests / test (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled
wsg_grabber: scrape /wsg/, download every new video, triage with keep/pass
New package. Walks every thread on 4chan's /wsg/, downloads each video it has
not seen, and reviews them one at a time: mpv embedded in a Tk window via --wid
with Keep/Pass/Undo directly beneath the picture.

Dedupe is structural rather than a convention. Every post carrying a file also
carries md5 -- base64 MD5 of the whole file -- so it is the sqlite primary key.
An already-seen video costs zero bytes to skip and a byte-identical repost in
another thread collapses onto the existing row. Verified against a live file:
the API's Nz9OEKdMuMZEdYE6eLTKmA== matched base64(md5(bytes)) exactly.

Undo is unbounded and survives quitting: the verdict trail lives in the index,
not in memory, so yesterday's decisions can still be taken back.

No downloaded video is ever deleted. A pass moves the file to trash/ and leaves
it there; files.apply_move refuses to overwrite an existing destination, so the
guarantee holds by construction rather than per caller.

Three bugs only running it could find, each caught from evidence rather than
guesswork:
  - the video stayed black: a <Configure> handler set mpv's geometry, fighting
    the automatic sizing --wid already does
  - playback never advanced: refresh() ran every tick and re-sent loadfile ~7x
    a second, restarting the video continuously
  - it froze on one video around the 26th while filenames kept advancing: mpv
    pushes an event stream down the same socket and nothing drained it, so the
    buffer filled and mpv stopped reading commands. Isolated to command #35
    without a drain thread; all 60 honoured with one.

A five-lens review then found nine more, all reproduced before fixing: a failed
mpv start stranded the non-daemon worker forever; undo could clobber a file in
incoming/; a NUL-bearing md5 poisoned the index permanently; an oversized int
killed the worker with no event; gzip bombs on both hosts were accepted; the
board endpoints were fetched unconditionally forever; and Session.missing could
corrupt the undo trail.

mpv runs as a subprocess over its JSON IPC socket rather than via python-mpv,
which resolves libmpv at import time and would fail on CI while passing here.
Third-party surface is requests alone, already declared.

277 package tests, 100% branch coverage on all 22 modules, no suppressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:55:30 +02:00

371 lines
12 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
def test_an_oversized_leftover_is_discarded_before_requesting(
tmp_path: Path,
) -> None:
part = tmp_path / "1.webm.part"
part.write_bytes(b"x" * 9999)
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 "Range" not in session.get.call_args.kwargs["headers"]
assert result.outcome is Outcome.COMPLETED
def test_a_rejected_range_restarts_from_zero(tmp_path: Path) -> None:
part = tmp_path / "1.webm.part"
part.write_bytes(_BODY[:100])
session = MagicMock()
session.get.return_value = _response(416)
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.TRANSIENT
assert not part.exists()
def test_shutdown_aborts_mid_stream_and_keeps_the_partial(tmp_path: Path) -> None:
"""Stopping must be immediate and must leave a resumable .part behind."""
session = MagicMock()
response = _response(200)
response.iter_content.return_value = [b"a" * 10, b"b" * 10, b"c" * 10]
session.get.return_value = response
transfer = net.Transfer(
url="https://i.4cdn.org/wsg/1.webm",
part_path=tmp_path / "1.webm.part",
expected_md5="whatever" + "=" * 16,
expected_size=30,
should_stop=lambda: True,
)
result = net.download(session, transfer)
assert result.outcome is Outcome.ABORTED
def test_empty_chunks_are_skipped(tmp_path: Path) -> None:
"""requests yields keep-alive chunks; they must not corrupt the digest."""
session = MagicMock()
response = _response(200)
response.iter_content.return_value = [b"", _BODY, b""]
session.get.return_value = response
result = net.download(session, _transfer(tmp_path))
assert result.outcome is Outcome.COMPLETED
def test_size_of_missing_file_is_zero(tmp_path: Path) -> None:
assert net.size_of(tmp_path / "nope") == 0
def test_a_compressed_body_is_refused(tmp_path: Path) -> None:
"""We ask for identity; requests would silently expand a gzip bomb."""
session = MagicMock()
session.get.return_value = _response(
200,
body=_BODY,
headers={"Content-Encoding": "gzip"},
)
result = net.download(session, _transfer(tmp_path))
assert result.outcome is Outcome.TRANSIENT
assert result.message is not None
assert "Content-Encoding" in result.message
def test_a_body_larger_than_advertised_is_abandoned(tmp_path: Path) -> None:
"""A server that lies about its size must not fill the disk."""
session = MagicMock()
response = _response(200)
response.iter_content.return_value = [b"x" * (1024 * 1024)] * 4
session.get.return_value = response
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=16,
should_stop=lambda: False,
)
result = net.download(session, transfer)
assert result.outcome is Outcome.TRANSIENT
assert not transfer.part_path.exists()
def test_an_unknown_size_is_not_treated_as_over_budget(tmp_path: Path) -> None:
"""fsize 0 means the API did not say; that must not block the download."""
session = MagicMock()
session.get.return_value = _response(200, body=_BODY)
result = net.download(session, _transfer(tmp_path, size=0))
assert result.outcome is Outcome.COMPLETED
def test_an_oversized_api_response_is_refused() -> None:
session = MagicMock()
response = _response(200)
response.content = b"[" + b"1," * 20_000_000 + b"1]"
session.get.return_value = response
with pytest.raises(ValueError, match="exceeded"):
net.get_json(session, "https://a.4cdn.org/wsg/threads.json")
def test_the_api_session_refuses_compression() -> None:
assert API_HEADERS["Accept-Encoding"] == "identity"