248 lines
11 KiB
Python
248 lines
11 KiB
Python
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# dependencies = ["click", "httpx", "torf", "pytest"]
|
|
# ///
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
from unittest.mock import patch
|
|
|
|
from click.testing import CliRunner
|
|
import httpx
|
|
import pytest
|
|
import torf
|
|
|
|
import torrentify as script
|
|
|
|
|
|
@pytest.mark.parametrize("kind", ["file", "directory", "mixed"])
|
|
def test_create_selected_content(tmp_path: Path, kind: str) -> None:
|
|
source = tmp_path / "first.txt"
|
|
source.write_bytes(b"first")
|
|
directory = tmp_path / "folder"
|
|
directory.mkdir()
|
|
(directory / "nested.txt").write_bytes(b"nested")
|
|
(tmp_path / "unselected.txt").write_bytes(b"unselected")
|
|
paths = {"file": (source,), "directory": (directory,), "mixed": (source, directory)}[kind]
|
|
output = tmp_path / "result.torrent"
|
|
result = CliRunner().invoke(cli=script.main, args=["create", *map(str, paths), "--output", str(output)])
|
|
assert result.exit_code == 0, result.output
|
|
torrent = torf.Torrent.read(filepath=output)
|
|
assert torrent.private is True
|
|
assert torrent.created_by == script.AUTHOR
|
|
assert list(torrent.trackers.flat) == list(script.ANNOUNCE_URLS)
|
|
expected = {"file": b"first", "directory": b"nested", "mixed": b"firstnested"}[kind]
|
|
assert torrent.metainfo["info"]["pieces"] == hashlib.sha1(expected).digest()
|
|
if kind == "mixed":
|
|
assert [item["path"] for item in torrent.metainfo["info"]["files"]] == [
|
|
["first.txt"], ["folder", "nested.txt"],
|
|
]
|
|
if kind == "directory":
|
|
assert torrent.name == "folder"
|
|
assert torrent.metainfo["info"]["files"][0]["path"] == ["nested.txt"]
|
|
|
|
|
|
def test_reject_inputs_in_different_directories(tmp_path: Path) -> None:
|
|
source = tmp_path / "first.txt"
|
|
source.write_bytes(b"first")
|
|
directory = tmp_path / "folder"
|
|
directory.mkdir()
|
|
nested = directory / "nested.txt"
|
|
nested.write_bytes(b"nested")
|
|
result = CliRunner().invoke(cli=script.main, args=["create", str(source), str(nested)])
|
|
assert result.exit_code == 1
|
|
assert "All inputs must be in the same directory" in result.output
|
|
assert not list(tmp_path.glob("*.torrent"))
|
|
|
|
|
|
def test_create_rejects_empty_and_existing_output(tmp_path: Path) -> None:
|
|
source = tmp_path / "file.txt"
|
|
source.touch()
|
|
with pytest.raises(ValueError, match="no nonempty files"):
|
|
script.create_torrent(paths=(source,), output=tmp_path / "result.torrent")
|
|
source.write_bytes(b"content")
|
|
output = script.create_torrent(paths=(source,), output=tmp_path / "result.torrent")
|
|
original = output.read_bytes()
|
|
with pytest.raises(FileExistsError):
|
|
script.create_torrent(paths=(source,), output=output)
|
|
assert output.read_bytes() == original
|
|
with pytest.raises(ValueError, match="overwrite a source"):
|
|
script.create_torrent(paths=(source,), output=source, overwrite=True)
|
|
assert source.read_bytes() == b"content"
|
|
|
|
|
|
def test_modify_metadata_without_content(tmp_path: Path) -> None:
|
|
source = tmp_path / "content.txt"
|
|
source.write_bytes(b"content")
|
|
output = script.create_torrent(paths=(source,), output=tmp_path / "original.torrent")
|
|
before = torf.Torrent.read(filepath=output)
|
|
source.unlink()
|
|
result = CliRunner().invoke(cli=script.main, args=[
|
|
"modify", str(output), "--announce-url", "https://tracker.example/announce",
|
|
"--announce-url", "udp://tracker.example:80/announce", "--author", "new author",
|
|
"--name", "new name", "--no-private",
|
|
])
|
|
assert result.exit_code == 0, result.output
|
|
after = torf.Torrent.read(filepath=output)
|
|
assert after.name == "new name"
|
|
assert after.created_by == "new author"
|
|
assert after.private is False
|
|
assert list(after.trackers.flat) == ["https://tracker.example/announce", "udp://tracker.example:80/announce"]
|
|
assert after.metainfo["info"]["pieces"] == before.metainfo["info"]["pieces"]
|
|
|
|
|
|
def test_modify_preserves_omitted_fields_and_original(tmp_path: Path) -> None:
|
|
source = tmp_path / "content.txt"
|
|
source.write_bytes(b"content")
|
|
original = script.create_torrent(paths=(source,), output=tmp_path / "original.torrent")
|
|
original_bytes = original.read_bytes()
|
|
output = tmp_path / "modified.torrent"
|
|
script.modify_torrent(torrent_path=original, output=output, author="replacement")
|
|
before = torf.Torrent.read(filepath=original)
|
|
after = torf.Torrent.read(filepath=output)
|
|
assert original.read_bytes() == original_bytes
|
|
assert after.infohash == before.infohash
|
|
assert after.private == before.private
|
|
assert after.trackers == before.trackers
|
|
assert after.name == before.name
|
|
with pytest.raises(torf.WriteError):
|
|
script.modify_torrent(torrent_path=original, output=output)
|
|
|
|
|
|
@pytest.mark.parametrize("added_key", ["torrent-added", "torrent-duplicate"])
|
|
@pytest.mark.parametrize("url", ["http://transmission:9091", "http://transmission:9091/transmission/rpc"])
|
|
def test_enqueue_negotiates_session_and_sets_remote_directory(tmp_path: Path, added_key: str, url: str) -> None:
|
|
torrent_path = tmp_path / "content.torrent"
|
|
torrent_path.write_bytes(b"torrent data")
|
|
requests = []
|
|
|
|
def respond(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
if len(requests) == 1:
|
|
return httpx.Response(status_code=409, headers={"X-Transmission-Session-Id": "session-token"})
|
|
return httpx.Response(status_code=200, json={
|
|
"result": "success", "arguments": {added_key: {"hashString": "torrent-hash"}},
|
|
})
|
|
|
|
client = httpx.Client(transport=httpx.MockTransport(handler=respond), auth=httpx.BasicAuth("user", "pass:word"))
|
|
with patch.object(script.httpx, "Client", return_value=client) as factory:
|
|
result = script.enqueue_torrent(
|
|
transmission_url=url, credentials="user:pass:word",
|
|
torrent_path=torrent_path, download_path="/remote/downloads",
|
|
)
|
|
assert result == "torrent-hash"
|
|
assert factory.call_args.kwargs["auth"]._auth_header == client.auth._auth_header
|
|
assert len(requests) == 2
|
|
assert requests[1].headers["X-Transmission-Session-Id"] == "session-token"
|
|
assert str(requests[1].url) == "http://transmission:9091/transmission/rpc"
|
|
payload = json.loads(requests[1].content)
|
|
assert payload["method"] == "torrent-add"
|
|
assert payload["arguments"]["download-dir"] == "/remote/downloads"
|
|
assert payload["arguments"]["paused"] is False
|
|
assert base64.b64decode(payload["arguments"]["metainfo"]) == torrent_path.read_bytes()
|
|
|
|
|
|
@pytest.mark.parametrize("status, result", [(401, "success"), (200, "invalid torrent"), (409, "success")])
|
|
def test_enqueue_reports_failures(tmp_path: Path, status: int, result: str) -> None:
|
|
torrent_path = tmp_path / "content.torrent"
|
|
torrent_path.write_bytes(b"torrent data")
|
|
|
|
def respond(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(status_code=status, headers={"X-Transmission-Session-Id": "token"}, json={"result": result})
|
|
|
|
client = httpx.Client(transport=httpx.MockTransport(handler=respond))
|
|
with patch.object(script.httpx, "Client", return_value=client):
|
|
with pytest.raises((httpx.HTTPStatusError, ValueError)):
|
|
script.enqueue_torrent(
|
|
transmission_url="http://transmission:9091", credentials=None,
|
|
torrent_path=torrent_path, download_path="/remote/downloads",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("use_environment", [False, True])
|
|
def test_enqueue_command(tmp_path: Path, use_environment: bool) -> None:
|
|
torrent_path = tmp_path / "content.torrent"
|
|
torrent_path.write_bytes(b"torrent data")
|
|
arguments = ["enqueue", str(torrent_path), "--download-path", "/remote/downloads"]
|
|
environment = {"TRANSMISSION_HOST": "http://transmission:9091", "TRANSMISSION_AUTH": "user:password"}
|
|
if not use_environment:
|
|
arguments.extend(["--transmission-url", environment["TRANSMISSION_HOST"], "--credentials", environment["TRANSMISSION_AUTH"]])
|
|
with patch.object(script, "enqueue_torrent", return_value="torrent-hash") as enqueue:
|
|
result = CliRunner().invoke(cli=script.main, args=arguments, env=environment if use_environment else {})
|
|
assert result.exit_code == 0, result.output
|
|
enqueue.assert_called_once_with(
|
|
transmission_url="http://transmission:9091", credentials="user:password",
|
|
torrent_path=torrent_path, download_path=Path("/remote/downloads"),
|
|
)
|
|
|
|
|
|
def test_enqueue_command_reports_error(tmp_path: Path) -> None:
|
|
torrent_path = tmp_path / "content.torrent"
|
|
torrent_path.write_bytes(b"torrent data")
|
|
with patch.object(script, "enqueue_torrent", side_effect=ValueError("Transmission rejected torrent")):
|
|
result = CliRunner().invoke(cli=script.main, args=[
|
|
"enqueue", str(torrent_path), "--host", "http://transmission:9091",
|
|
"--download-path", "/remote/downloads",
|
|
])
|
|
assert result.exit_code == 1
|
|
assert "Error: Transmission rejected torrent" in result.output
|
|
|
|
|
|
@pytest.mark.parametrize("directory", [False, True])
|
|
@pytest.mark.parametrize("json_output", [False, True])
|
|
def test_inspect_reports_properties_and_files_without_content(
|
|
tmp_path: Path, directory: bool, json_output: bool,
|
|
) -> None:
|
|
folder = tmp_path / "content"
|
|
folder.mkdir()
|
|
source = folder / "first.txt"
|
|
source.write_bytes(b"first")
|
|
if directory:
|
|
(folder / "second.txt").write_bytes(b"second")
|
|
output = script.create_torrent(paths=(folder if directory else source,), output=tmp_path / "content.torrent")
|
|
original = output.read_bytes()
|
|
source.unlink()
|
|
arguments = ["inspect", str(output)]
|
|
if json_output:
|
|
arguments.append("--json")
|
|
result = CliRunner().invoke(cli=script.main, args=arguments)
|
|
assert result.exit_code == 0, result.output
|
|
assert result.stderr == ""
|
|
if json_output:
|
|
report = json.loads(result.stdout)
|
|
assert report["properties"]["author"] == script.AUTHOR
|
|
assert report["properties"]["private"] is True
|
|
assert report["properties"]["announce_urls"] == [list(script.ANNOUNCE_URLS)]
|
|
assert report["properties"]["size"] == (11 if directory else 5)
|
|
assert report["files"] == (
|
|
[{"path": "content/first.txt", "size": 5}, {"path": "content/second.txt", "size": 6}]
|
|
if directory else [{"path": "first.txt", "size": 5}]
|
|
)
|
|
else:
|
|
assert f"Author: {script.AUTHOR}\n" in result.stdout
|
|
assert "Private: yes\n" in result.stdout
|
|
assert f"Size: {11 if directory else 5} bytes\n" in result.stdout
|
|
assert f"Tier 1: {script.ANNOUNCE_URLS[0]}\n" in result.stdout
|
|
assert f"Files ({2 if directory else 1}):\n" in result.stdout
|
|
assert f" {'content/' if directory else ''}first.txt: 5 bytes\n" in result.stdout
|
|
if directory:
|
|
assert " content/second.txt: 6 bytes\n" in result.stdout
|
|
assert output.read_bytes() == original
|
|
|
|
|
|
def test_inspect_rejects_invalid_torrent(tmp_path: Path) -> None:
|
|
torrent_path = tmp_path / "invalid.torrent"
|
|
torrent_path.write_bytes(b"invalid")
|
|
result = CliRunner().invoke(cli=script.main, args=["inspect", str(torrent_path)])
|
|
assert result.exit_code == 1
|
|
assert "Error:" in result.output
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(args=[str(Path(__file__).resolve()), *sys.argv[1:]]))
|