Files
shelfmark/tests/prowlarr/test_torrent_utils.py
T
Alex a560089ce3 Patch: Script improvements + bug fixes (#591)
- Add new booklore API file formats
- Renamed cookie for better login persistence with reverse proxy
- Updated fs.py to try hardlink before atomic move from tmp dir
- Fix transmission URL parsing 
- Fix scenario where file processing of huge files starves the
healthcheck
- Large enhancements to custom scripting, including passing JSON
download info, more consistent activation across output types,
decoupling from staging behavior, and added full documentation.
2026-02-06 13:51:23 +00:00

432 lines
15 KiB
Python

"""
Tests for torrent utility functions.
Tests:
- parse_transmission_url
- bencode_encode/decode
- extract_info_hash_from_torrent
- extract_hash_from_magnet
"""
import base64
import hashlib
import pytest
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
parse_transmission_url,
bencode_decode,
bencode_encode,
extract_info_hash_from_torrent,
extract_hash_from_magnet,
)
class TestParseTransmissionUrl:
"""Tests for parse_transmission_url function."""
def test_parse_simple_url(self):
"""Test parsing a simple URL with host and port."""
protocol, host, port, path = parse_transmission_url("http://localhost:9091")
assert protocol == "http"
assert host == "localhost"
assert port == 9091
assert path == "/transmission/rpc"
def test_parse_url_with_custom_port(self):
"""Test parsing URL with custom port."""
protocol, host, port, path = parse_transmission_url("http://myserver:8080")
assert protocol == "http"
assert host == "myserver"
assert port == 8080
assert path == "/transmission/rpc"
def test_parse_url_with_path(self):
"""Test parsing URL with existing path."""
protocol, host, port, path = parse_transmission_url("http://localhost:9091/transmission/rpc")
assert protocol == "http"
assert host == "localhost"
assert port == 9091
assert path == "/transmission/rpc"
def test_parse_url_with_partial_path(self):
"""Test parsing URL with partial path appends /rpc."""
protocol, host, port, path = parse_transmission_url("http://localhost:9091/custom")
assert protocol == "http"
assert host == "localhost"
assert port == 9091
assert path == "/custom/transmission/rpc"
def test_parse_url_with_trailing_slash(self):
"""Test parsing URL with trailing slash."""
protocol, host, port, path = parse_transmission_url("http://localhost:9091/")
assert protocol == "http"
assert host == "localhost"
assert port == 9091
assert path == "/transmission/rpc"
def test_parse_url_without_port(self):
"""Test parsing URL without port uses default 9091."""
protocol, host, port, path = parse_transmission_url("http://transmission")
assert protocol == "http"
assert host == "transmission"
assert port == 9091
assert path == "/transmission/rpc"
def test_parse_https_url(self):
"""Test parsing HTTPS URL."""
protocol, host, port, path = parse_transmission_url("https://secure.transmission.local:9091")
assert protocol == "https"
assert host == "secure.transmission.local"
assert port == 9091
assert path == "/transmission/rpc"
def test_parse_url_with_ip_address(self):
"""Test parsing URL with IP address."""
protocol, host, port, path = parse_transmission_url("http://192.168.1.100:9091")
assert protocol == "http"
assert host == "192.168.1.100"
assert port == 9091
assert path == "/transmission/rpc"
def test_parse_empty_url_uses_defaults(self):
"""Test parsing empty URL uses localhost defaults."""
protocol, host, port, path = parse_transmission_url("")
assert protocol == "http"
assert host == "localhost"
assert port == 9091
assert path == "/transmission/rpc"
class TestBencodeDecode:
"""Tests for bencode decoding."""
def test_decode_integer(self):
"""Test decoding integers."""
result, remaining = bencode_decode(b"i42e")
assert result == 42
assert remaining == b""
def test_decode_negative_integer(self):
"""Test decoding negative integers."""
result, remaining = bencode_decode(b"i-42e")
assert result == -42
def test_decode_zero(self):
"""Test decoding zero."""
result, remaining = bencode_decode(b"i0e")
assert result == 0
def test_decode_large_integer(self):
"""Test decoding large integers."""
result, remaining = bencode_decode(b"i999999999999e")
assert result == 999999999999
def test_decode_string(self):
"""Test decoding byte strings."""
result, remaining = bencode_decode(b"5:hello")
assert result == b"hello"
assert remaining == b""
def test_decode_empty_string(self):
"""Test decoding empty string."""
result, remaining = bencode_decode(b"0:")
assert result == b""
def test_decode_unicode_string(self):
"""Test decoding unicode bytes."""
data = "tëst".encode("utf-8")
encoded = f"{len(data)}:".encode() + data
result, remaining = bencode_decode(encoded)
assert result == data
def test_decode_list(self):
"""Test decoding lists."""
result, remaining = bencode_decode(b"li1ei2ei3ee")
assert result == [1, 2, 3]
assert remaining == b""
def test_decode_empty_list(self):
"""Test decoding empty list."""
result, remaining = bencode_decode(b"le")
assert result == []
def test_decode_nested_list(self):
"""Test decoding nested lists."""
result, remaining = bencode_decode(b"lli1eeli2eee")
assert result == [[1], [2]]
def test_decode_mixed_list(self):
"""Test decoding list with mixed types."""
result, remaining = bencode_decode(b"l5:helloi42ee")
assert result == [b"hello", 42]
def test_decode_dict(self):
"""Test decoding dictionaries."""
result, remaining = bencode_decode(b"d3:key5:valuee")
assert result == {b"key": b"value"}
assert remaining == b""
def test_decode_empty_dict(self):
"""Test decoding empty dictionary."""
result, remaining = bencode_decode(b"de")
assert result == {}
def test_decode_complex_structure(self):
"""Test decoding complex nested structures."""
# Dict with string, int, and list values
data = b"d3:agei25e4:name4:John5:itemsli1ei2ei3eee"
result, remaining = bencode_decode(data)
assert result == {
b"age": 25,
b"name": b"John",
b"items": [1, 2, 3],
}
def test_decode_invalid_data_raises(self):
"""Test that invalid data raises ValueError."""
with pytest.raises(ValueError):
bencode_decode(b"x")
class TestBencodeEncode:
"""Tests for bencode encoding."""
def test_encode_integer(self):
"""Test encoding integers."""
assert bencode_encode(42) == b"i42e"
assert bencode_encode(-42) == b"i-42e"
assert bencode_encode(0) == b"i0e"
def test_encode_bytes(self):
"""Test encoding byte strings."""
assert bencode_encode(b"hello") == b"5:hello"
assert bencode_encode(b"") == b"0:"
def test_encode_string(self):
"""Test encoding regular strings (UTF-8 encoded)."""
assert bencode_encode("hello") == b"5:hello"
assert bencode_encode("") == b"0:"
def test_encode_list(self):
"""Test encoding lists."""
assert bencode_encode([1, 2, 3]) == b"li1ei2ei3ee"
assert bencode_encode([]) == b"le"
def test_encode_dict(self):
"""Test encoding dictionaries."""
result = bencode_encode({b"key": b"value"})
assert result == b"d3:key5:valuee"
def test_encode_dict_keys_sorted(self):
"""Test that dictionary keys are sorted."""
# Keys should be sorted: a < m < z
result = bencode_encode({b"z": 1, b"a": 2, b"m": 3})
assert result == b"d1:ai2e1:mi3e1:zi1ee"
def test_encode_nested_structure(self):
"""Test encoding nested structures."""
data = {b"list": [1, 2, 3], b"num": 42}
result = bencode_encode(data)
assert result == b"d4:listli1ei2ei3ee3:numi42ee"
def test_encode_invalid_type_raises(self):
"""Test that invalid types raise ValueError."""
with pytest.raises(ValueError):
bencode_encode(3.14) # floats not supported
class TestBencodeRoundTrip:
"""Tests for encoding then decoding (roundtrip)."""
def test_roundtrip_integer(self):
"""Test roundtrip for integers."""
original = 12345
encoded = bencode_encode(original)
decoded, _ = bencode_decode(encoded)
assert decoded == original
def test_roundtrip_bytes(self):
"""Test roundtrip for byte strings."""
original = b"hello world"
encoded = bencode_encode(original)
decoded, _ = bencode_decode(encoded)
assert decoded == original
def test_roundtrip_list(self):
"""Test roundtrip for lists."""
original = [1, 2, b"three", [4, 5]]
encoded = bencode_encode(original)
decoded, _ = bencode_decode(encoded)
assert decoded == original
def test_roundtrip_dict(self):
"""Test roundtrip for dictionaries."""
original = {b"name": b"test", b"value": 123}
encoded = bencode_encode(original)
decoded, _ = bencode_decode(encoded)
assert decoded == original
def test_roundtrip_complex_torrent_like_structure(self):
"""Test roundtrip for a structure similar to a torrent file."""
original = {
b"announce": b"http://tracker.example.com/announce",
b"info": {
b"name": b"TestFile.txt",
b"length": 1024,
b"piece length": 16384,
b"pieces": b"\x00" * 20, # SHA1 hashes
},
}
encoded = bencode_encode(original)
decoded, _ = bencode_decode(encoded)
assert decoded == original
class TestExtractInfoHash:
"""Tests for extracting info hash from torrent files."""
def test_extract_hash_from_simple_torrent(self):
"""Test extracting hash from a simple torrent structure."""
info_dict = {
b"name": b"test.txt",
b"length": 100,
b"piece length": 16384,
b"pieces": b"\x00" * 20,
}
torrent = {b"info": info_dict}
torrent_bytes = bencode_encode(torrent)
result = extract_info_hash_from_torrent(torrent_bytes)
# Should return a 40-character hex string
assert result is not None
assert len(result) == 40
assert all(c in "0123456789abcdef" for c in result)
def test_extract_hash_returns_none_for_invalid(self):
"""Test that invalid data returns None."""
assert extract_info_hash_from_torrent(b"not a torrent") is None
assert extract_info_hash_from_torrent(b"") is None
def test_extract_hash_returns_none_without_info(self):
"""Test that torrent without info dict returns None."""
torrent = {b"announce": b"http://tracker.example.com"}
torrent_bytes = bencode_encode(torrent)
result = extract_info_hash_from_torrent(torrent_bytes)
assert result is None
def test_extract_hash_is_consistent(self):
"""Test that same torrent always produces same hash."""
info_dict = {b"name": b"consistent.txt", b"length": 500}
torrent = {b"info": info_dict}
torrent_bytes = bencode_encode(torrent)
hash1 = extract_info_hash_from_torrent(torrent_bytes)
hash2 = extract_info_hash_from_torrent(torrent_bytes)
assert hash1 == hash2
def test_extract_hash_different_for_different_torrents(self):
"""Test that different torrents produce different hashes."""
torrent1 = {b"info": {b"name": b"file1.txt", b"length": 100}}
torrent2 = {b"info": {b"name": b"file2.txt", b"length": 100}}
hash1 = extract_info_hash_from_torrent(bencode_encode(torrent1))
hash2 = extract_info_hash_from_torrent(bencode_encode(torrent2))
assert hash1 != hash2
def test_extract_hash_v2_without_pieces(self):
"""Use SHA-256 when torrent lacks v1 pieces."""
info_dict = {
b"meta version": 2,
b"file tree": {b"test.txt": {b"": {b"length": 123}}},
b"piece length": 16384,
}
torrent = {b"info": info_dict}
torrent_bytes = bencode_encode(torrent)
expected = hashlib.sha256(bencode_encode(info_dict)).hexdigest().lower()
assert extract_info_hash_from_torrent(torrent_bytes) == expected
class TestExtractHashFromMagnet:
"""Tests for extracting hash from magnet links."""
def test_extract_hash_from_hex_magnet(self):
"""Test extracting 40-char hex hash from magnet link."""
magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test"
result = extract_hash_from_magnet(magnet)
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
def test_extract_hash_from_base32_magnet(self):
"""Test extracting 32-char base32 hash from magnet link."""
# Base32 encoded hash: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
magnet = "magnet:?xt=urn:btih:ABCDEFGHIJKLMNOPQRSTUVWXYZ234567&dn=test"
result = extract_hash_from_magnet(magnet)
# Should be converted to hex (lowercase)
assert result is not None
assert len(result) == 40
assert all(c in "0123456789abcdef" for c in result)
def test_extract_hash_uppercase_hex(self):
"""Test that uppercase hex is converted to lowercase."""
magnet = "magnet:?xt=urn:btih:3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0&dn=test"
result = extract_hash_from_magnet(magnet)
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
def test_extract_hash_no_btih(self):
"""Test that magnets without btih return None."""
magnet = "magnet:?dn=test"
result = extract_hash_from_magnet(magnet)
assert result is None
def test_extract_hash_invalid_format(self):
"""Test that invalid hash format returns None."""
magnet = "magnet:?xt=urn:btih:invalid&dn=test"
result = extract_hash_from_magnet(magnet)
assert result is None
def test_extract_hash_not_magnet(self):
"""Test that non-magnet URLs return None."""
result = extract_hash_from_magnet("https://example.com/file.torrent")
assert result is None
def test_extract_hash_empty_string(self):
"""Test that empty string returns None."""
result = extract_hash_from_magnet("")
assert result is None
def test_extract_hash_complex_magnet(self):
"""Test extracting from complex magnet with many parameters."""
magnet = (
"magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
"&dn=Ubuntu+22.04"
"&tr=udp://tracker.example.com:80"
"&tr=udp://tracker2.example.com:6969"
"&xl=12345"
)
result = extract_hash_from_magnet(magnet)
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
def test_extract_hash_from_btmh_hex(self):
"""Test extracting v2 hash from btmh (hex multihash)."""
digest = bytes(range(1, 33))
multihash = b"\x12\x20" + digest
magnet = f"magnet:?xt=urn:btmh:{multihash.hex()}&dn=test"
result = extract_hash_from_magnet(magnet)
assert result == digest.hex()
def test_extract_hash_from_btmh_base32(self):
"""Test extracting v2 hash from btmh (base32 multihash)."""
digest = bytes(range(1, 33))
multihash = b"\x12\x20" + digest
b32 = base64.b32encode(multihash).decode("ascii").rstrip("=")
magnet = f"magnet:?xt=urn:btmh:{b32}&dn=test"
result = extract_hash_from_magnet(magnet)
assert result == digest.hex()