testsAndMisc/python_pkg/wsg_grabber/tests/test_downloader_part2.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

334 lines
10 KiB
Python

"""More tests for the background worker.
Split from test_downloader.py to stay under this repo's 500-line-per-file cap;
the fixtures are duplicated because each test module must stand alone.
"""
from __future__ import annotations
import queue
import threading
import time
from typing import TYPE_CHECKING, cast
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.wsg_grabber import db, downloader, net, store, store_threads
from python_pkg.wsg_grabber.constants import IDLE_RESCAN_S, THREADS_URL
from python_pkg.wsg_grabber.models import (
DownloadEvent,
DownloadResult,
JsonResponse,
Outcome,
RemoteFile,
ScanFinished,
TaskKind,
ThreadRef,
WorkerFailed,
)
from python_pkg.wsg_grabber.states import FileState
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_MD5 = "Nz9OEKdMuMZEdYE6eLTKmA=="
_BODY = b"video bytes"
@pytest.fixture
def deps(tmp_path: Path) -> Iterator[downloader.WorkerDeps]:
"""Build worker dependencies backed by a sandboxed index.
Args:
tmp_path: Per-test temporary directory.
Yields:
downloader.WorkerDeps: Ready dependencies; resources closed after.
"""
incoming = tmp_path / "incoming"
incoming.mkdir()
conn = db.open_index(tmp_path / "index.db")
session = MagicMock()
built = downloader.WorkerDeps(
conn=conn,
session=session,
incoming=incoming,
events=queue.SimpleQueue(),
stop=threading.Event(),
include_archive=False,
)
try:
yield built
finally:
conn.close()
def _json(
payload: object,
*,
last_modified: str | None = None,
not_modified: bool = False,
not_found: bool = False,
) -> JsonResponse:
"""Build a JsonResponse with sensible defaults.
Args:
payload: Decoded body.
last_modified: Header value the response carried.
not_modified: Whether the server answered 304.
not_found: Whether the thread has gone.
Returns:
JsonResponse: Response double.
"""
return JsonResponse(
payload=payload,
last_modified=last_modified,
not_modified=not_modified,
not_found=not_found,
)
def _remote(md5: str = _MD5, tim: int = 1) -> RemoteFile:
"""Build a RemoteFile.
Args:
md5: Identity.
tim: CDN stem.
Returns:
RemoteFile: Fixture value.
"""
return RemoteFile(
md5=md5,
tim=tim,
ext=".webm",
orig_name="clip",
fsize=len(_BODY),
width=480,
height=360,
thread_no=7,
post_no=tim,
)
def _drain(events: queue.SimpleQueue[DownloadEvent]) -> list[DownloadEvent]:
"""Pull everything currently queued.
Args:
events: Queue to empty.
Returns:
list[DownloadEvent]: Events in order.
"""
out: list[DownloadEvent] = []
while not events.empty():
out.append(events.get_nowait())
return out
def test_download_progress_is_persisted(deps: downloader.WorkerDeps) -> None:
worker = downloader.Worker(deps)
store.record_files(deps.conn, [_remote()])
def fake_download(_session: object, transfer: net.Transfer) -> DownloadResult:
assert transfer.on_progress is not None
transfer.on_progress(7)
return DownloadResult(outcome=Outcome.ABORTED, bytes_done=7)
with patch.object(net, "download", side_effect=fake_download):
worker.run_once()
assert deps.conn.execute("SELECT bytes_done FROM files").fetchone()[0] == 7
def test_claim_losing_a_race_is_a_no_op(deps: downloader.WorkerDeps) -> None:
worker = downloader.Worker(deps)
store.record_files(deps.conn, [_remote()])
with (
patch.object(store, "claim_next", return_value=None),
patch.object(
net,
"download",
) as never,
):
worker._download_one()
never.assert_not_called()
def test_deleted_upstream_files_are_written_off(
deps: downloader.WorkerDeps,
) -> None:
worker = downloader.Worker(deps)
store.record_files(deps.conn, [_remote()])
thread = {"posts": [{"no": 1, "md5": _MD5, "filedeleted": 1}]}
# Driven directly: with a file queued, run_once would always pick DOWNLOAD
# over SCAN, so the thread fetch under test would never happen.
with patch.object(net, "get_json", return_value=_json(thread)):
worker._fetch_thread(ThreadRef(7, 100))
assert store.counts(deps.conn)[FileState.GONE.value] == 1
assert store.pending_downloads(deps.conn) == 0
def test_an_already_downloaded_file_survives_upstream_deletion(
deps: downloader.WorkerDeps,
) -> None:
"""Once the bytes are ours, the post being deleted is irrelevant."""
worker = downloader.Worker(deps)
store.record_files(deps.conn, [_remote()])
store.mark_downloaded(deps.conn, _MD5, "1-x.webm")
thread = {"posts": [{"no": 1, "md5": _MD5, "filedeleted": 1}]}
with patch.object(net, "get_json", return_value=_json(thread)):
worker._fetch_thread(ThreadRef(7, 100))
assert store.state_of(deps.conn, _MD5) is FileState.READY
def test_idle_announces_completion_exactly_once(
deps: downloader.WorkerDeps,
) -> None:
worker = downloader.Worker(deps)
with patch.object(net, "get_json", return_value=_json([])):
worker.run_once()
with patch.object(deps.stop, "wait"):
assert worker.run_once() is TaskKind.IDLE
assert worker.run_once() is TaskKind.IDLE
finished = [e for e in _drain(deps.events) if isinstance(e, ScanFinished)]
assert len(finished) == 1
def test_the_board_is_rescanned_after_the_quiet_period(
deps: downloader.WorkerDeps,
) -> None:
"""A finished sweep goes quiet, then polls again for new content."""
worker = downloader.Worker(deps)
with patch.object(net, "get_json", return_value=_json([])):
worker.run_once()
with patch.object(deps.stop, "wait"):
assert worker.run_once() is TaskKind.IDLE
with patch(
"python_pkg.wsg_grabber.downloader.time.monotonic",
return_value=time.monotonic() + IDLE_RESCAN_S + 1,
):
assert worker.run_once() is TaskKind.IDLE
# the quiet period has elapsed, so the next step walks the board again
assert worker.run_once() is TaskKind.SCAN
finished = [e for e in _drain(deps.events) if isinstance(e, ScanFinished)]
assert len(finished) == 1
def test_rate_limiting_waits_between_requests(
deps: downloader.WorkerDeps,
) -> None:
worker = downloader.Worker(deps)
listing = [{"threads": [{"no": 7, "last_modified": 100}]}]
with (
patch.object(
net,
"get_json",
side_effect=[_json(listing), _json({"posts": []})],
),
patch.object(deps.stop, "wait") as waited,
):
worker.run_once() # the first request is free
worker.run_once() # the second must be spaced out
delays = [call.args[0] for call in waited.call_args_list if call.args]
assert delays
assert max(delays) <= 1.0
def test_run_reports_a_crash_and_closes_resources(
deps: downloader.WorkerDeps,
) -> None:
worker = downloader.Worker(deps)
with patch.object(worker, "run_once", side_effect=RuntimeError("kaput")):
worker.run()
events = _drain(deps.events)
assert any(
isinstance(event, WorkerFailed) and event.message == "kaput" for event in events
)
cast("MagicMock", deps.session).close.assert_called_once()
def test_run_exits_cleanly_when_stopped(deps: downloader.WorkerDeps) -> None:
deps.stop.set()
worker = downloader.Worker(deps)
worker.run()
assert _drain(deps.events) == []
cast("MagicMock", deps.session).close.assert_called_once()
@pytest.mark.timeout(10)
def test_start_runs_and_joins_a_real_thread(tmp_path: Path) -> None:
"""The one test that starts a thread; it must stop on demand."""
incoming = tmp_path / "incoming"
incoming.mkdir()
stop = threading.Event()
stop.set()
def build() -> downloader.WorkerDeps:
"""Open the connection on the worker thread, which must own it."""
return downloader.WorkerDeps(
conn=db.open_index(tmp_path / "index.db"),
session=MagicMock(),
incoming=incoming,
events=queue.SimpleQueue(),
stop=stop,
include_archive=False,
)
thread = downloader.start(build)
thread.join(timeout=5)
assert not thread.is_alive()
def test_the_board_endpoints_are_fetched_conditionally(
deps: downloader.WorkerDeps,
) -> None:
"""Without this an idle reviewer re-fetches the board unconditionally forever."""
deps.include_archive = True
worker = downloader.Worker(deps)
stamp = "Mon, 27 Jul 2026 10:00:00 GMT"
with patch.object(
net,
"get_json",
return_value=_json([], last_modified=stamp),
) as fake:
worker.run_once()
sent_first = [call.args[2] for call in fake.call_args_list]
worker._catalog_fresh = False
fake.reset_mock()
worker.run_once()
sent_again = [call.args[2] for call in fake.call_args_list]
assert sent_first == [None, None] # nothing stored yet
assert sent_again == [stamp, stamp] # the stamp goes back
def test_a_304_on_the_board_keeps_the_stored_stamp(
deps: downloader.WorkerDeps,
) -> None:
worker = downloader.Worker(deps)
stamp = "Mon, 27 Jul 2026 10:00:00 GMT"
with patch.object(net, "get_json", return_value=_json([], last_modified=stamp)):
worker.run_once()
with patch.object(net, "get_json", return_value=_json(None, not_modified=True)):
worker._catalog_fresh = False
worker.run_once()
assert store_threads.cached_last_modified(deps.conn, THREADS_URL) == stamp
def test_a_worker_that_cannot_build_its_resources_reports_it(
tmp_path: Path,
) -> None:
"""Otherwise the reviewer waits on downloads that will never come."""
reported: list[str] = []
thread = downloader.start(
lambda: (_ for _ in ()).throw(OSError("no database")),
reported.append,
)
thread.join(timeout=5)
assert not thread.is_alive()
assert reported
assert "no database" in reported[0]