from __future__ import annotations from pathlib import Path import pytest from sticker_downloader import cli from sticker_downloader.cli import ( EXIT_FAILURES, EXIT_OK, EXIT_USAGE, UsageError, build_parser, collect_interactively, main, ) from sticker_downloader.results import FileOutcome, FileStatus, PackResult @pytest.fixture(autouse=True) def isolated_cwd(tmp_path, monkeypatch): """Run each test in a clean directory with no ambient token.""" monkeypatch.chdir(tmp_path) monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False) return tmp_path @pytest.fixture def captured_run(monkeypatch): """Replace the network layer and record what the CLI asked it to do.""" recorded: dict = {} def _install(results: list[PackResult]) -> dict: async def fake_download(token, references, config, progress): recorded["token"] = token recorded["references"] = list(references) recorded["config"] = config return results monkeypatch.setattr(cli, "_download", fake_download) return recorded return _install def ok_result(name: str = "Pack") -> PackResult: return PackResult( reference=name, name=name, outcomes=[FileOutcome(Path(f"{name}/001.webp"), FileStatus.DOWNLOADED, size=10)], ) def failed_result(name: str = "Pack") -> PackResult: return PackResult(reference=name, name=name, error="sticker pack not found") # -- parser --------------------------------------------------------------- def test_parser_defaults(): args = build_parser().parse_args([]) assert args.packs == [] assert args.from_file is None assert args.types is None assert args.output == Path("downloads") assert args.concurrency == 8 assert args.retries == 3 assert args.write_metadata is True assert args.pack_thumbnail is True assert args.overwrite is False assert args.dry_run is False def test_version_exits_cleanly(capsys): with pytest.raises(SystemExit) as excinfo: main(["--version"]) assert excinfo.value.code == EXIT_OK assert "1.0.0" in capsys.readouterr().out def test_verbose_and_quiet_are_mutually_exclusive(): with pytest.raises(SystemExit): build_parser().parse_args(["-v", "-q", "Pack"]) # -- argument validation -------------------------------------------------- def test_packs_and_from_file_conflict(capsys): assert main(["Pack", "--from-file", "urls.txt"]) == EXIT_USAGE assert "not both" in capsys.readouterr().err def test_missing_from_file_is_a_usage_error(capsys): assert main(["--from-file", "nope.txt"]) == EXIT_USAGE assert "not found" in capsys.readouterr().err def test_invalid_file_type_is_reported_before_connecting(capsys): assert main(["Pack", "--types", "gif"]) == EXIT_USAGE assert "gif" in capsys.readouterr().err def test_all_references_invalid_is_a_usage_error(capsys): assert main(["https://example.com/x"]) == EXIT_USAGE assert "no valid sticker pack references" in capsys.readouterr().err def test_missing_token_explains_how_to_set_one(capsys): assert main(["Pack"]) == EXIT_USAGE assert "BotFather" in capsys.readouterr().err def test_no_packs_and_no_tty_is_a_usage_error(capsys, monkeypatch): monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False) assert main([]) == EXIT_USAGE assert "no packs given" in capsys.readouterr().err # -- wiring --------------------------------------------------------------- def test_successful_run_returns_zero(captured_run, monkeypatch): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "token-from-env") recorded = captured_run([ok_result()]) assert main(["https://t.me/addstickers/Pack", "--types", "webp,png"]) == EXIT_OK assert recorded["token"] == "token-from-env" assert recorded["references"] == ["https://t.me/addstickers/Pack"] assert recorded["config"].file_types == frozenset({"webp", "png"}) def test_failing_pack_returns_one(captured_run, monkeypatch): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t") captured_run([ok_result("A"), failed_result("B")]) assert main(["A", "B"]) == EXIT_FAILURES def test_token_flag_beats_environment(captured_run, monkeypatch): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "from-env") recorded = captured_run([ok_result()]) assert main(["Pack", "--token", "from-flag"]) == EXIT_OK assert recorded["token"] == "from-flag" def test_token_is_read_from_env_file(captured_run, isolated_cwd): (isolated_cwd / "custom.env").write_text("TELEGRAM_BOT_TOKEN=from-file\n") recorded = captured_run([ok_result()]) assert main(["Pack", "--env-file", "custom.env"]) == EXIT_OK assert recorded["token"] == "from-file" def test_flags_reach_the_config(captured_run, monkeypatch, tmp_path): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t") recorded = captured_run([ok_result()]) exit_code = main( [ "Pack", "--output", str(tmp_path / "out"), "--concurrency", "4", "--retries", "5", "--overwrite", "--emoji-names", "--no-metadata", "--no-pack-thumbnail", "--dry-run", ] ) assert exit_code == EXIT_OK config = recorded["config"] assert config.output_dir == tmp_path / "out" assert config.concurrency == 4 assert config.retries == 5 assert config.overwrite is True assert config.emoji_names is True assert config.write_metadata is False assert config.pack_thumbnail is False assert config.dry_run is True def test_default_types_are_all_when_not_interactive(captured_run, monkeypatch): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t") recorded = captured_run([ok_result()]) assert main(["Pack"]) == EXIT_OK assert recorded["config"].sorted_file_types == ["webp", "tgs", "webm", "png"] def test_references_come_from_file(captured_run, monkeypatch, isolated_cwd): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t") (isolated_cwd / "packs.txt").write_text( "# mine\nhttps://t.me/addstickers/A\n\nB\n", encoding="utf-8" ) recorded = captured_run([ok_result()]) assert main(["--from-file", "packs.txt"]) == EXIT_OK assert recorded["references"] == ["https://t.me/addstickers/A", "B"] def test_references_come_from_stdin(captured_run, monkeypatch): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t") monkeypatch.setattr(cli.sys, "stdin", _FakeStdin("A\nB\n")) recorded = captured_run([ok_result()]) assert main(["--from-file", "-"]) == EXIT_OK assert recorded["references"] == ["A", "B"] def test_invalid_lines_are_warned_about_but_do_not_stop_the_run( captured_run, monkeypatch, isolated_cwd, capsys ): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t") (isolated_cwd / "packs.txt").write_text("Good\nme/addstickers/Broken\n") captured_run([ok_result()]) assert main(["--from-file", "packs.txt", "--quiet"]) == EXIT_OK class _FakeStdin: def __init__(self, text: str) -> None: self._text = text def read(self) -> str: return self._text def isatty(self) -> bool: return False # -- interactive mode ----------------------------------------------------- def _scripted(answers: list[str]): remaining = list(answers) def ask(_prompt: str) -> str: return remaining.pop(0) return ask def test_interactive_single_url(): types, references = collect_interactively( types_given=None, ask=_scripted(["webp,png", "1", "https://t.me/addstickers/A t.me/addstickers/B"]), say=lambda _msg: None, ) assert types == "webp,png" assert references == ["https://t.me/addstickers/A", "t.me/addstickers/B"] def test_interactive_defaults_to_all_types_and_option_one(): types, references = collect_interactively( types_given=None, ask=_scripted(["", "", "A"]), say=lambda _msg: None, ) assert types == "" assert references == ["A"] def test_interactive_skips_the_type_prompt_when_given(tmp_path): types, references = collect_interactively( types_given="webp", ask=_scripted(["1", "A"]), say=lambda _msg: None, ) assert types == "webp" assert references == ["A"] def test_interactive_reads_the_url_file(tmp_path): url_file = tmp_path / "urls.txt" url_file.write_text("https://t.me/addstickers/A\nB\n", encoding="utf-8") _types, references = collect_interactively( types_given="all", url_file=url_file, ask=_scripted(["2"]), say=lambda _msg: None, ) assert references == ["https://t.me/addstickers/A", "B"] def test_interactive_missing_url_file(tmp_path): with pytest.raises(UsageError): collect_interactively( types_given="all", url_file=tmp_path / "absent.txt", ask=_scripted(["2"]), say=lambda _msg: None, ) def test_interactive_rejects_a_bad_choice(): with pytest.raises(UsageError): collect_interactively( types_given="all", ask=_scripted(["9"]), say=lambda _msg: None ) def test_interactive_rejects_an_empty_url(): with pytest.raises(UsageError): collect_interactively( types_given="all", ask=_scripted(["1", " "]), say=lambda _msg: None ) def test_interactive_validates_types_before_anything_else(): with pytest.raises(Exception, match="gif"): collect_interactively( types_given=None, ask=_scripted(["gif"]), say=lambda _msg: None ) def test_interactive_path_is_used_when_stdin_is_a_tty( captured_run, monkeypatch, tmp_path ): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "t") monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True) monkeypatch.setattr( cli, "collect_interactively", lambda **_kwargs: ("webp", ["Pack"]) ) recorded = captured_run([ok_result()]) assert main([]) == EXIT_OK assert recorded["references"] == ["Pack"] assert recorded["config"].file_types == frozenset({"webp"}) def test_ctrl_d_during_prompts_aborts_cleanly(monkeypatch, capsys): monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True) def raise_eof(**_kwargs): raise EOFError monkeypatch.setattr(cli, "collect_interactively", raise_eof) assert main([]) == EXIT_USAGE assert "Aborted" in capsys.readouterr().err