mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 02:27:18 +01:00
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>
371 lines
12 KiB
Python
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"
|