Skip to content

Test/harden tests against mutation survivors - #10

Merged
Squazz merged 46 commits into
mainfrom
test/harden-tests-against-mutation-survivors
Oct 21, 2026
Merged

Squazz merged 46 commits into
mainfrom
test/harden-tests-against-mutation-survivors

Conversation

@Squazz

@Squazz Squazz commented Oct 19, 2026

Copy link
Copy Markdown
Owner

No description provided.

Squazz and others added 30 commits October 19, 2026 07:14
Mechanical formatting only, so later diffs (and the PostToolUse format
hook) don't mix whitespace churn into behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- tests/helpers.py: isolated_env()/scrubbed_environ() strip SKIP_RATIO_CHECK,
  CLEANUP_*, TRANSMISSION_* and FETCH_TORRENTS_* (except the log dir), and
  make_temp_dir() removes its dir on cleanup.
- test_fetch_torrents applies isolated_env() module-wide, so an exported
  SKIP_RATIO_CHECK=true no longer fails 9 ratio-gate tests.
- The stagnation watch-file test pins ft.date instead of using date.today().
- The unsafe-name watch-file test asserts on disk instead of patching the
  process-wide os.remove (which log rotation also calls).
- Every mkdtemp/mkstemp in tests is now removed after the test.
- CI runs the suite a second time with a hostile environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds ShouldFetchTorrentBoundaryTests: ratio exactly 1.0 vs 0.999 (standard
and Devuan), only-newer / same-version-only records, highest older version
deciding, other-arch isolation, Devuan ignoring non-Devuan and same-release
entries, live-over-removed priority both ways, SKIP_RATIO_CHECK, unknown
distro and unparseable names. Every ShouldFetchTorrent* class now pins
SKIP_RATIO_CHECK=false in setUp.

Hand-applied mutants listed in the task are all killed; the one survivor
(dropping 'n in ratios or' in the removed-history loop) is equivalent,
since setdefault() already keeps the live ratio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers missing file, no markers, last-segment-wins, START without END,
END before the last START, name stripping, integer ratios not matching,
and duplicate names. get_previous_ratios is now fully covered by the
normal unittest run; the subprocess restart test stays as-is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bdecode silently accepted a byte string whose declared length ran past
the end of the data (b'4:ab' -> b'ab'), so a truncated .torrent decoded
into a wrong info dict and hash. Missing 'e'/':' terminators and
non-numeric integers/lengths leaked bare ValueErrors from bytes.index()
and int(); int() also accepted '1_0' and whitespace. All of these now
raise BencodeError naming the problem and its index. BencodeError still
subclasses ValueError, so existing callers are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full mutmut 2.5.1 re-run after tasks 10-13 left four killable
survivors in the touched functions:
- should_fetch_torrent: 'continue' -> 'break' and 'or' -> 'and' in the
  removed-history skip (a skipped legacy entry first, and no parse-error
  warning for entries without a ratio).
- bdecode: '>=' -> '>' on the end-of-data guard and the remaining-bytes
  arithmetic in the truncation message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The old loop kept an end_index that was reset on every START, which made
the 'end_index > start_index' check and the initial 'end_index = None'
redundant - mutmut reported both as surviving (equivalent) mutants.
Selecting 'after the last START, up to the last END after it' directly
removes them. A new test pins the existing 'last END wins' behavior
before the rewrite; mutmut now kills 28/28 mutants in the function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edge-value tests for plan_cleanup (ratio == min_ratio, default floor),
plan_stagnation_cleanup / _stagnation_anchor (anchor exactly window_days
old, delta == min_ratio_delta, oldest qualifying sample, defaults),
update_ratio_history (sample interval and prune horizon edges) and
plan_disk_pressure_cleanup (usage == threshold, small positive threshold).
Skipped torrents (Devuan, unknown distro, unparseable, no anchor) are put
before valid candidates so continue->break mutants fail, and missing/None
ratios and added_dates are asserted to count as zero/oldest.

A focused mutmut run kills every mutant in these seven functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- pyproject.toml holds ruff and mypy settings so local runs and CI agree.
  BLE001 is ignored project-wide (broad except-and-log is the run's
  error-handling design); transmission_rpc gets a scoped
  ignore_missing_imports.
- Real findings fixed: an unused variable in fetch_ubuntu_lts, the
  NamedTemporaryFile in evaluate_old_release_demand now uses a context
  manager (and also cleans up if the write fails), and the RPC payload is
  typed so requests' json= accepts it.
- Test-side findings fixed (explicit check=False, HTTPError instead of a
  bare Exception, instance attribute instead of a mutable class default,
  unused unpacked values, combined with-statements).
- ruff and mypy are pinned in requirements-dev.txt; the CI test job
  installs it and runs the three checks before the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- requirements-dev.txt pins coverage and mutmut 2.5.1.
- pyproject.toml configures branch coverage over the three modules and
  mutmut's paths/runner, so 'coverage run' and 'mutmut run' need no flags.
- .gitignore covers mutmut's cache and output.
- CI runs the suite under coverage and prints the report (no threshold
  yet; task 27 sets one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers first run with credentials (exact file contents, port forwarding
defaulted off), existing files keeping unrelated keys and their port
forwarding setting, no-override runs leaving the file untouched, the
open-whitelist warning on stderr, invalid peer limits being dropped
silently (current behavior), corrupt files starting fresh, the default
/config path, and running the module as a script. configure_transmission.py
is now at 100% line and branch coverage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/test_daily_run.py executes fetch_torrents.py as __main__ with HTTP,
the Transmission client, the RPC port check, disk usage and the clock
faked at their boundaries, and state/log files in a temp dir. It pins:
per-outcome download counting and the summary line, distro selection,
the ratio gate fed from the previous run's ratio log (blocked below 1.0,
allowed at 1.0), one failing distro not stopping the others, which
cleanup runs for CLEANUP_KEEP_ONLY_LATEST_VERSION, the disk-usage limit
running after cleanup and its errors being logged, and seed ratios being
written for the next run. No production code changed; /watch is
redirected in the test because watch_dir is fixed at import time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ubuntu distro pattern was '^ubuntu-|^lbuntu-|^xbuntu-', but
fetch_ubuntu_lts() names them lubuntu-*/xubuntu-*. get_distro() returned
None for both flavours, so they bypassed the previous-version ratio gate
and were never grouped for old-version/stagnation/disk-pressure cleanup.

Behavior change: after this, a new Lubuntu/Xubuntu release is only
fetched once the previous one reached ratio 1.0, and superseded
Lubuntu/Xubuntu releases become cleanup candidates like Ubuntu's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daily run was ~150 lines of __main__ reading os.environ, date.today()
and a live Transmission client inline, and importing the module opened
(and truncated) log files under /logs. Now:

- configure_logging(log_dir, env) attaches the stream, rotating file and
  ratio handlers; import opens nothing. It replaces only its own handlers.
- load_config(env) parses every env var once into a frozen Config. A bad
  number is recorded and raised by Config.require() where it is used, so
  cleanup and the disk limit still just log and skip, as before.
- run(config, client_factory=, today=, now=, fetchers=, disk_usage=,
  download_path=) is the daily run, returning a RunSummary.
- cleanup_old_versions / cleanup_stagnant_torrents take the client and
  today; enforce_disk_usage_limit takes a client factory, disk_usage and
  the download path. make_client_factory() carries the RPC credentials.

The runpy characterization tests from task 17 passed unchanged against
the refactor before being rewritten to call run() directly with
FakeTransmission/FakeDisk; one runpy test keeps the __main__ wiring.
Also adds LoadConfigTests, parse_version_type arch-variant cases (task
24) and updates CLAUDE.md's test-setup notes. fetch_torrents.py branch
coverage: 91%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds tests for bencode str encoding, missing scrape counts, UDP connect
and scrape responses that are short, carry trailing bytes, an error
action or another transaction's ID, distinct connect/scrape transaction
IDs, the default timeout, and address resolution. Also pins the primary
announce going first, the single 'announce' substitution in
derive_scrape_url and scrape_torrent's default timeout.

The UDP receive buffer size becomes UDP_RECV_BUFFER_BYTES with
`# pragma: no mutate`: any size above the largest response read works,
so no test can tell 2048 from 2049.

Focused mutmut run on torrent_scrape.py: every function at 100%
(string-literal-only survivors excluded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
transmission_rpc isn't apk-only: alpine:latest (3.24) ships
py3-transmission-rpc 7.0.11, which is also on PyPI. Pin that version in
requirements-test.txt and drop the sys.modules MagicMock stubs:

- helpers.FakeTorrent builds a real transmission_rpc.Torrent from RPC
  fields, so tests only read attributes the library has. DuckTorrent
  stays for the few tests of production's defensive None/float branches.
- helpers.FakeTransmission binds each call against the real Client
  signature, so an argument the library wouldn't accept raises TypeError
  (tests/test_transmission_double.py pins this).
- make_client_factory tests patch Client with autospec=True.
- log_seed_ratios_via_http's request is checked against the library's
  RpcMethod and TORRENT_GET_ARGS tables, and its ratio ordering/None
  handling is pinned.
- mypy now type-checks against the real library (override removed).

Renaming Client's username= or remove_torrent's delete_data= in
production now fails the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 22)

The image was built and pushed without ever being started, and the test
job only exercised pip packages on Ubuntu, never the apk ones the
container runs with.

- scripts/smoke_test.sh starts the image with --network none, as root and
  with RUN_AS_NON_ROOT=true PUID=PGID=1234. It checks entrypoint.sh got
  going, configure_transmission.py applied an override to settings.json,
  transmission-daemon is running (as uid 1234 in the non-root case),
  /config is owned by 1234:1234, and every module imports. About 5 s.
- A Dockerfile `test` stage runs the unit suite on the image's own
  packages. BuildKit skips it in a normal build, and the final stage is
  the runtime image unchanged.
- The build job runs both before any push step, so PRs get them too.

Checked locally against a deliberately broken image (configure step
exiting 1): the smoke test fails with the container logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each fetcher was only tested against a hand-written ~5-line snippet of
what its page was assumed to look like, and fetch_kali_latest had no
test at all.

- scripts/record_fixtures.py captures every page the fetch_* scrapers
  read into tests/fixtures/pages/<distro>/<slug>.<ext>, with the URL and
  capture time in a .meta.json. Captured 2026-10-19.
- tests/test_scrapers.py replays them. Each fetcher (including both
  *_old_versions) must produce exactly the candidates read off the
  snapshot by hand. Also covers connection errors, HTTP 404/500, and
  pages missing the element a scraper looks for.
- helpers.fake_response() returns a real requests.Response and replaces
  the 19 ad-hoc FakeResponse classes, plus the one in test_daily_run.
  helpers.RecordedPages serves the fixtures by URL and records any
  unexpected request.
- helpers.block_network() makes every test module refuse real socket
  connections.

Bug fixed: fetch_ubuntu_lts, fetch_kali_latest, fetch_kali_old_versions,
fetch_devuan_latest and fetch_linuxmint_cinnamon never checked the HTTP
status. An error page became "no releases" ({} with nothing logged, for
Ubuntu and Kali old versions) or a misleading "could not detect a
version" warning. They now call raise_for_status() and log the HTTP
error like the other fetchers.

fetch_torrents.py branch coverage: 91% -> 96%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge check is deterministic, but the distro sites, the trackers and
the published image (apk upgrade at start) keep moving. Nothing checked
them until a user noticed missing torrents.

.github/workflows/drift.yml runs on a weekly schedule and by hand, and is
not part of the PR check:
- scrapers: scripts/live_check.py runs every fetcher live. Each must
  return candidates whose names match the distro pattern with a
  parseable version and https .torrent URLs, at least one answering 200.
  On failure the pages are re-recorded and uploaded as an artifact.
- tracker: scrapes a current Ubuntu torrent; counts must be integers.
- transmission: scripts/transmission_roundtrip.py, run inside the
  published image against its own daemon. It adds a self-made
  tracker-less torrent via /watch, reads it like cleanup does, logs
  ratios over raw RPC and removes it with delete_data=True, all through
  fetch_torrents' own load_config/make_client_factory.
- published-image: scripts/smoke_test.sh against :latest with network
  and apk upgrade. The script now takes SMOKE_NETWORK and
  SMOKE_SKIP_PACKAGE_UPDATES.
- mutation: mutmut results uploaded for trend tracking; never fails.
- Any failure opens, or comments on, a single issue labelled `drift`.

All checks run locally on 2026-10-19 and pass. The round trip ran
against an image built from this branch, since the current :latest
predates load_config() and make_client_factory().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 3,400-line module becomes test_ratio_gate, test_fetchers,
test_cleanup, test_persistence, test_logging and test_old_release_demand.
The config, run-summary and client-factory tests move to test_daily_run.

Shared pieces move to tests/helpers.py: DuckTorrent, fixed_date,
names_of, the repo-root sys.path bootstrap (helpers.REPO_ROOT), and
isolate_module(), which every module's setUpModule now calls (env
scrubbing plus the network guard) in place of per-file boilerplate.

Purely mechanical: the set of Class.method test IDs is identical before
and after (378), and the suite and hostile-env suite pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/test_live_check.py (task 23) imports scripts/live_check.py, so the
in-image unit suite failed to import it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…part 2)

- Presence-only checks become exact values: fetch_ubuntu_lts' full
  candidate dict, fetch_kali_old_versions' full result,
  warn_if_open_without_auth's message, the cleanup parse-error warnings,
  the session-id request's timeout (== 15), and assertIs(..., True) for
  should_fetch_torrent.
- test_logging's two hand-rolled RPC response classes become
  helpers.fake_response(); no ad-hoc response class is left in tests/.
- The load/save round-trip, missing-file and corrupt-file tests, written
  four times (ratio history, removed history, old-release check state,
  settings.json), are now one JsonStoreContract mixin. It also checks
  that a corrupt file is reported (logger or stderr) and that saving
  again leaves only the file. 12 -> 16 tests.

Suite: 382 tests, green; hostile-env run and in-image run also green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Full mutmut runs (whole suite as the runner):
  - critical fetch_torrents logic: 70.9% -> 99.2% (256/258)
  - torrent_scrape: 82.6% -> 100%
  - configure_transmission: 89.8% -> 96.7%
  The two remaining critical survivors (has_unmet_demand's defaults for
  a missing seeder/leecher count) are now killed by
  test_missing_counts_mean_zero, checked by hand-applying each mutant,
  so 258/258.
- coverage: [tool.coverage.report] fail_under = 94 (measured total
  96.78% minus about 2), which CI's `coverage report` step now enforces.
- tasks/README.md: targets table updated with the new numbers, every
  task marked done, and the remaining non-critical survivors (I/O and
  orchestration) listed as follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FakeTransmission now keeps a torrent removed without delete_data (the real
Client's default) in data_left_on_disk, and FakeDisk counts it as used, so
a removal only frees space the way it would for real. Before this, flipping
delete_data=True to False in enforce_disk_usage_limit or
cleanup_stagnant_torrents passed the whole suite.

New tests pin:
- delete_data=True on disk-pressure and stagnation removals
- the disk-pressure entry gate at exactly the threshold, just below it,
  a 1% threshold, a zero-size disk, a custom download path with a single
  connection, the per-removal warning, and a zero ratio recorded as 0.0
- stagnation's default 30-day window and 0.02 delta, custom
  window_days/min_ratio_delta wiring (including the history prune), the
  kept/removed log lines, and a zero ratio sampled as 0.0

Kills mutants #839 #843 #844 #846 #857 #864 #868 #924 #925 #930 #940 plus
an argument swap and a dropped window_days in the stagnation path. Left as
equivalent: #858 (disk total turning 0 mid-loop), #861 (break vs continue
once under threshold), #863/#929 (a real Torrent always has .ratio).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
check_old_releases_for_demand() was tested with evaluate_old_release_demand()
patched out, and the daily run with check_old_releases_for_demand() patched
out, so the thresholds, skip paths and counters were never really exercised:
21 mutants survived in the check, 7 in the evaluation and 2 in the run's
wiring.

- OldReleaseTracker fakes only the two network boundaries (the .torrent
  download and the tracker scrape, which reads the temp file it is given),
  so the real evaluation runs in between.
- One multi-candidate test covers every outcome in a single pass (removed
  x2, unsafe name, already present, recently checked, below floor, zero
  seeders, unknown demand, 404, wanted x2) behind a distro with no
  discovery and one that finds nothing. It asserts the counts, watch files,
  state, request order, log lines and that the caller's state isn't mutated.
- evaluate_old_release_demand(): the temp copy is scraped and removed even
  when the scrape fails, temp-file failure, scrape timeout pass-through,
  404 vs 500 logging, and the download timeout.
- Daily run: the mocked test_old_release_check_runs_only_when_enabled is
  replaced by an off-by-default test and OldReleaseRunTests, which drive
  the env thresholds, recheck interval, saved check state and removed
  history (including SKIP_REMOVED=false) through the real check.
  RunTestCase now holds the shared setUp, and _run() takes fetchers.
- test_below_absolute_threshold_is_no_demand used a zero-seeder swarm, so
  it passed with the leecher floor deleted. It now uses a seeded swarm and
  no ratio requirement.

All targeted mutants are killed except #715/#729 (the .get("seeders", 0)
default, which can't apply because scrape results always carry seeders).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run() and _fetch_latest() had 14 surviving mutants: no test produced two
of the same download outcome, the clock never advanced, the downloads-folder
usage line was never asserted, and RPC credentials were checked by mocking
log_seed_ratios_via_http.

- FakeWorld.post now behaves like Transmission's RPC: a request without the
  session id gets a 409 carrying it, and the torrent-get records its auth.
  FakeWorld.disk_usage records the path it was asked about.
- The credentials test asserts the auth the RPC endpoint actually received,
  plus a no-credentials case, instead of mocking the internal function.
- New run tests:
  - two of every outcome (added, existing, not found, failed, and fetchers
    that found nothing), with a removed-history ratio-blocked candidate
    first that must not stop the rest
  - low-demand images skipped by default and fetched with
    FETCH_TORRENTS_INCLUDE_LOW_DEMAND=true
  - elapsed time from a clock that advances
  - the exact "Downloads folder usage" line and the /downloads path
- _run() also takes `now`.

All 17 targeted mutants in run()/_fetch_latest() are killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
configure_logging() was tested only for which handlers it builds. No record
was ever sent through them, so the [ratio] split, FETCH_TORRENTS_ALWAYS_LOG,
the log level and the formatter were unasserted. parse_log_level() had no
direct test, and the always-log prefixes were a silent string contract with
the run's message wording. 17 mutants survived across these.

- ParseLogLevelTests: unset, blank, digits, names (case and whitespace),
  unknown, and a non-int digit ('²') falling back to the default.
- LogRoutingTests emit debug/info/warning, an always-log progress line and
  [ratio] lines, then assert exactly what lands in stderr,
  fetch_torrents.log and the ratio log for:
  - the defaults
  - a higher FETCH_TORRENTS_LOG_LEVEL
  - ALWAYS_LOG off with the LOG_LEVEL fallback
  - FETCH_TORRENTS_LOG_LEVEL beating LOG_LEVEL
  It also checks the timestamped format and that the logger doesn't
  propagate.
- A run-level test checks every get_always_log_prefixes() entry matches a
  message the daily run really emits, so rewording one now fails.
- test_still_starts_with_run_complete_for_always_log_filter never touched
  the filter. Renamed to test_is_let_through_by_the_always_log_filter, it
  now runs the summary through ImportantMessageFilter at ERROR level.

All targeted mutants are killed except #82 (propagate = None), which
logging treats the same as False.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 2026-10-19 test-health review's top five items are done (90c7f8f,
db38abe, b4d010a, aa81ef3). The rest of its findings become tasks 28-38:
production seams for the RPC wait, UDP transaction ids and live_check;
atomic .torrent writes; scrape_torrent fall-through and SSRF edges;
configure_transmission gaps; the remaining mutation survivors; mechanical
tidy-up; fixture consolidation; toolchain hardening; and a final
re-measure with a coverage ratchet.

The README records the new baseline at aa81ef3: 423 tests, 99% branch
coverage, fetch_torrents.py mutation 95.4% (1027/1076).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Squazz and others added 16 commits October 19, 2026 15:47
download_torrent() and the old-release add wrote straight to
/watch/<name>.torrent, so a write that died part-way left a partial file
that counted as "existing" forever. Both now write a dot-prefixed
.<name>.torrent.part (outside transmission's *.torrent glob) and
os.replace() it into place. A failed old-release write is logged,
skipped and left unrecorded so the next run retries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wait_for_transmission_rpc() now takes connect/clock/sleep seams and
uses time.monotonic() rather than wall-clock time. run() takes the
readiness check as wait_for_rpc, so the daily-run tests drive the
"RPC unavailable" path through it instead of patching both the
function and socket.create_connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
is_reachable(), check_scrapers(), check_tracker() and main() take their
collaborators (HEAD/GET, fetchers, reachability, scrape, the checks map)
as defaulted arguments, so the drift job's HEAD-405 fallback,
MAY_BE_EMPTY, "one reachable is enough", the tracker for/else and
integer validation, and exit codes 0/1/2 are all tested offline.

The test module now loads live_check from its path instead of putting
scripts/ on sys.path, and fake_response() marks its body consumed so a
response can be used as a context manager like a real one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A parametrised test has tracker A fail by connection error, HTTP status,
failure reason, missing stats or a non-scrapable announce URL, and checks
B's exact %-encoded request URL, timeout and whole result. UDP calls are
asserted with exact (host, port, info_hash, timeout).

SSRF: an empty resolution is unsafe; multicast, unspecified and
IPv4-mapped internal addresses are pinned as disallowed (all already
were, so no production change). Hand-built Mock responses are replaced
by tracker_response() over helpers.fake_response, and the duplicated
resolver fakes by resolve_private().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ff S, pins (task 37)

- smoke_test.sh now waits for "Starting torrent fetch run." in
  /logs/fetch_torrents.log in both the root and RUN_AS_NON_ROOT runs, so
  a fetch script that crashes on import fails the build.
- The test job runs shellcheck v0.11.0 and hadolint v2.15.1 (pinned
  images). The two Dockerfile ignores (rolling alpine:latest, unpinned
  apk packages) are justified inline.
- ruff adds the bandit S family. The info hash uses
  sha1(usedforsecurity=False); test-only ignores are justified in
  pyproject.toml.
- CI Python is pinned to alpine:latest's 3.14, and requests and
  beautifulsoup4 to the apk versions (2.33.1, 4.15.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New tests pin the 30 s timeout on every fetcher and download, Arch/Kali/
Fedora selection and ordering, the unknown-distro warning text,
cleanup_old_versions' default floor and zero-ratio record, ratio
rounding, the credential fallback needing both halves, parse_bool's
default, RunSummary/Config defaults and immutability, the state-file
paths, and the state files' indented, sorted JSON.

Bug fix: a Fedora listing that links the newest torrent twice no longer
reports the duplicate as an old release. Both Fedora fetchers now share
one de-duplicating parser. Arch's max() and the disk-usage percentage
were simplified so their equivalent mutants are no longer generated; the
remaining accepted equivalents are listed in tasks/README.md.

Also pins types-requests: requests 2.33 (the apk pin from task 37) ships
no inline types, so mypy failed without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 29)

scrape_udp_tracker() takes new_transaction_id (default
_random_transaction_id), so tests pass a fixed id sequence instead of
patching os.urandom process-wide. The UDP tests use an autospec'd socket
and assert the connect and scrape datagrams byte for byte. The test
that guards the fresh scrape id now says so.

Every bare assertRaises in test_torrent_scrape.py now checks a message
fragment, and the duplicate short-response tests are folded together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lts (task 31)

main(settings_path=None) falls back to TRANSMISSION_SETTINGS_FILE, then
the new DEFAULT_SETTINGS_FILE constant. The default-path test patches
that constant, so it no longer depends on whether this machine has a
real /config/settings.json.

New tests pin a peer limit of exactly 1, case/whitespace-insensitive
TRANSMISSION_PORT_FORWARDING, and apply_overrides' whole result with
its input unchanged. Each subTest case gets its own temp dir. The
settings.json layout is recorded as an accepted equivalent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sts (task 34)

Expected values that came from production (ft._kali_torrent_urls,
ft.DEFAULT_DISTROS, ft.ALL_DISTROS) are now written out: the Kali image
list moved to helpers.kali_candidates(), shared with test_scrapers.py.
Config tests assert only the project's "ENV_VAR: " prefix, not
CPython's float()/int() error text. The import subprocess runs with
-W ignore.

Count-only and startswith/any() asserts now check names, exact log
lines and that nothing was removed (new FakeTransmission.removals).
parse_supported_distros' unknown-entry and fallback cases assert their
warnings. The plan_cleanup per-distro test is a subTest table plus a
combined run. ScraperTestCase.fetch() returns a Fetched result instead
of stashing self.pages and logging a dummy line; FailurePathTests checks
that one error naming the cause was logged, merges 404 into one HTTP
status test and renames LATEST_FETCHERS to SINGLE_PAGE_FETCHERS.

Removed duplicates of stricter tests: the Devuan no-link, Arch
no-releases and Kali single-version fetcher tests, the session-id
timeout test, and the second fallback warning test. Also removed the
duplicate __main__ block in test_cleanup.py and the unused
helpers.use_isolated_env. helpers.fixed_date now backs a test that
check_old_releases_for_demand() defaults today to date.today(); the
other old-release tests pass today explicitly.

Moved: config, parse_bool, client factory, run summary and value-type
tests to test_config.py; test_transmission_double.py plus
NetworkGuardTests to test_helpers.py; get_distro and the lubuntu ratio
gate out of ParseVersionTypeTests; distro_of into its own class.
CLAUDE.md lists the new modules. Only one _rpc_port_open stub was left,
so there was nothing to hoist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…task 35)

watch_dir is now FETCH_TORRENTS_WATCH_DIR (default /watch), read once at
import like log_dir, and documented in the README. Tests check the
default and the override in a fresh interpreter. MainWiringTests points
it, and FETCH_TORRENTS_LOG_DIR, at FakeWorld's temp dirs, and now routes
Arch so the runpy run really writes a torrent there.

get_rpc_credentials() is gone. load_config() and
log_seed_ratios_via_http()'s env fallback both use _rpc_credentials(env),
and Config.rpc_auth is the only rule for when both halves count. A test
pins the fallback to Config.rpc_auth.

New helpers: use_watch_dir(), use_state_files() (both return the temp
paths) and removed(name, ratio=None). Also FakeTransmissionRpc, the one
strict HTTP RPC fake: 409 plus session id, auth recorded, method and
fields checked against the pinned transmission_rpc. test_logging and
FakeWorld both use it in place of their own handshakes. All of these are
tested in test_helpers.py, and removed() is checked against
record_removal(). Every setUp that patched watch_dir or a state-file
global now calls the helpers.

test_old_release_demand gets demand(scrape, **overrides) with the daily
run's default thresholds. FakeWorld gets
hold_current_and_superseded_ubuntu(). The ratio-key tests are one table,
which adds the missing Kali and Arch high-ratio rows. CLAUDE.md
documents the new helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o 97

The full mutmut run for task 38 found three new behavioural survivors:
- fetch_kali_latest's "if not results" branch was unreachable
  (_kali_torrent_urls always returns its fixed list), so it is removed.
- The old-release write-failure branch from task 32 now has a test
  where a skip comes before the failed write and a later candidate
  still gets added, which kills "skipped = 1" and "continue -> break".

Coverage is 99.13% (branch), so fail_under goes from 94 to 97.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Full mutmut 2.5.1 runs at 209292c: fetch_torrents.py 99.3% (1040/1047),
torrent_scrape.py 99.6% (248/249), configure_transmission.py 96.8%
(90/93), critical set still 100%. Every behavioural survivor left is an
accepted equivalent, now including the FIPS-only usedforsecurity flag.
Branch coverage is 99.1% against the new fail_under = 97. The mutmut
docs note the stale-.pyc and Windows-encoding pitfalls hit here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fetch_torrents.py and configure_transmission.py are run as scripts, so
record them as 100755 in git. torrent_scrape.py is only imported, so its
shebang goes instead. Ruff on Windows can't see the exec bit, which is
why this only failed on the Linux runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ruff's EXE001 only sees the exec bit on Linux, so a 100644 shebang file
passes lint on Windows and fails on CI. test_repo_hygiene reads modes
from the git index, which works on every OS. It also caught
entrypoint.sh, which the Dockerfile chmods but git stored as 100644.
Both CLAUDE.md files note the rule and the update-index fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run() calls _check_old_releases() unguarded, so with
FETCH_TORRENTS_CHECK_OLD_RELEASES=true one invalid
FETCH_TORRENTS_OLD_RELEASE_* value aborts every daily run before ratio
logging, cleanup and the disk-usage limit. Found while reviewing this
branch's production changes; the behavior predates the branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Squazz
Squazz merged commit 9ffdbdf into main Oct 21, 2026
3 checks passed
@Squazz
Squazz deleted the test/harden-tests-against-mutation-survivors branch October 21, 2026 10:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant