| """Tests for the Phase 3a CLI ingestion scaffold (backend/cli.py). |
| |
| Covers parser routing and that each subcommand forwards the right args to the |
| existing ingestion functions — WITHOUT running ML (the underlying modules are |
| faked in sys.modules). A subprocess test enforces the key invariant: importing |
| cli + building the parser pulls in no torch (the lazy-import boundary the |
| eventual web/ingestion split depends on). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import subprocess |
| import sys |
| from types import SimpleNamespace |
|
|
| BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| sys.path.insert(0, BACKEND_DIR) |
|
|
| import cli |
|
|
|
|
| def test_parser_routes_subcommands_to_handlers(): |
| parser = cli.build_parser() |
|
|
| ns = parser.parse_args(["ingest", "video", "--natural-key", "K", "--label", "480p", "--keep-source"]) |
| assert ns.func is cli._cmd_ingest_video |
| assert ns.natural_key == "K" and ns.label == "480p" and ns.keep_source is True and ns.all is False |
|
|
| ns = parser.parse_args(["ingest", "vod", "--language", "S", "--redownload"]) |
| assert ns.func is cli._cmd_ingest_vod and ns.language == "S" and ns.redownload is True |
|
|
| ns = parser.parse_args(["ingest", "images", "--source", "web"]) |
| assert ns.func is cli._cmd_ingest_images and ns.source == "web" |
|
|
| ns = parser.parse_args(["process", "subtitles", "--language", "F", "--keep-html"]) |
| assert ns.func is cli._cmd_process_subtitles and ns.language == "F" and ns.keep_html is True |
|
|
| ns = parser.parse_args(["reindex", "embeddings", "--batch-size", "10"]) |
| assert ns.func is cli._cmd_reindex_embeddings and ns.batch_size == 10 |
|
|
|
|
| def test_ingest_vod_forwards_args(monkeypatch): |
| calls = {} |
| fake = SimpleNamespace( |
| download_vod=lambda lang, redownload=False: (calls.update(dl=(lang, redownload)) or {"ok": 1}), |
| combine_media_info=lambda lang: (calls.update(combine=lang) or {"a": {}, "b": {}}), |
| ) |
| monkeypatch.setitem(sys.modules, "jw_api", fake) |
|
|
| rc = cli._cmd_ingest_vod(SimpleNamespace(language="E", redownload=True)) |
|
|
| assert rc == 0 |
| assert calls["dl"] == ("E", True) |
| assert calls["combine"] == "E" |
|
|
|
|
| def test_ingest_vod_reports_error_when_download_fails(monkeypatch): |
| fake = SimpleNamespace(download_vod=lambda lang, redownload=False: None, combine_media_info=lambda lang: {}) |
| monkeypatch.setitem(sys.modules, "jw_api", fake) |
| assert cli._cmd_ingest_vod(SimpleNamespace(language="E", redownload=False)) == 1 |
|
|
|
|
| def test_ingest_video_single_forwards_args(monkeypatch): |
| calls = {} |
| fake = SimpleNamespace( |
| process_video=lambda nk, label, delete_video_after=None: ( |
| calls.update(pv=(nk, label, delete_video_after)) or {"status": "success"} |
| ), |
| process_all_local_videos=lambda **k: {"status": "success"}, |
| ) |
| monkeypatch.setitem(sys.modules, "process_video", fake) |
|
|
| rc = cli._cmd_ingest_video( |
| SimpleNamespace(natural_key="vid-1", label="720p", all=False, reprocess=False, keep_source=False) |
| ) |
|
|
| assert rc == 0 |
| assert calls["pv"] == ("vid-1", "720p", None) |
|
|
|
|
| def test_ingest_video_keep_source_forces_no_delete(monkeypatch): |
| calls = {} |
| fake = SimpleNamespace( |
| process_video=lambda nk, label, delete_video_after=None: ( |
| calls.update(d=delete_video_after) or {"status": "success"} |
| ), |
| process_all_local_videos=lambda **k: {"status": "success"}, |
| ) |
| monkeypatch.setitem(sys.modules, "process_video", fake) |
|
|
| cli._cmd_ingest_video( |
| SimpleNamespace(natural_key="v", label="720p", all=False, reprocess=False, keep_source=True) |
| ) |
|
|
| assert calls["d"] is False |
|
|
|
|
| def test_ingest_video_all_forwards_args(monkeypatch): |
| calls = {} |
| fake = SimpleNamespace( |
| process_video=lambda *a, **k: {"status": "success"}, |
| process_all_local_videos=lambda label, reprocess, delete_video_after: ( |
| calls.update(batch=(label, reprocess, delete_video_after)) or {"status": "success"} |
| ), |
| ) |
| monkeypatch.setitem(sys.modules, "process_video", fake) |
|
|
| cli._cmd_ingest_video( |
| SimpleNamespace(natural_key=None, label="720p", all=True, reprocess=True, keep_source=False) |
| ) |
|
|
| assert calls["batch"] == ("720p", True, None) |
|
|
|
|
| def test_ingest_video_requires_key_or_all_without_importing_ml(monkeypatch): |
| |
| |
| monkeypatch.delitem(sys.modules, "process_video", raising=False) |
| rc = cli._cmd_ingest_video( |
| SimpleNamespace(natural_key=None, label="720p", all=False, reprocess=False, keep_source=False) |
| ) |
| assert rc == 1 |
|
|
|
|
| def test_emit_exit_codes(): |
| assert cli._emit({"status": "error", "message": "x"}) == 1 |
| assert cli._emit({"status": "success"}) == 0 |
| assert cli._emit({"indexed": 3}) == 0 |
|
|
|
|
| def test_ingest_subtitles_forwards_args(monkeypatch): |
| calls = {} |
| monkeypatch.setitem(sys.modules, "jw_api", SimpleNamespace(combine_media_info=lambda lang: {"a": {}, "b": {}})) |
| monkeypatch.setitem( |
| sys.modules, |
| "subtitles_download", |
| SimpleNamespace( |
| process_all_media_for_subtitles=lambda media, lang: ( |
| calls.update(c=(len(media), lang)) or {"downloaded": 2} |
| ) |
| ), |
| ) |
| assert cli._cmd_ingest_subtitles(SimpleNamespace(language="E")) == 0 |
| assert calls["c"] == (2, "E") |
|
|
|
|
| def test_ingest_subtitles_errors_without_catalog(monkeypatch): |
| monkeypatch.setitem(sys.modules, "jw_api", SimpleNamespace(combine_media_info=lambda lang: {})) |
| monkeypatch.setitem( |
| sys.modules, "subtitles_download", |
| SimpleNamespace(process_all_media_for_subtitles=lambda *a: {}), |
| ) |
| assert cli._cmd_ingest_subtitles(SimpleNamespace(language="E")) == 1 |
|
|
|
|
| def test_ingest_images_publications_branch(monkeypatch): |
| calls = {} |
| monkeypatch.setitem( |
| sys.modules, "search_publication_images", |
| SimpleNamespace(index_all_publication_images=lambda: (calls.update(pub=True) or {"indexed": 5})), |
| ) |
| assert cli._cmd_ingest_images(SimpleNamespace(source="publications", language="E")) == 0 |
| assert calls.get("pub") is True |
|
|
|
|
| def test_ingest_images_web_branch_forwards_language(monkeypatch): |
| calls = {} |
| monkeypatch.setitem( |
| sys.modules, "web_images_processor", |
| SimpleNamespace(crawl_web_images=lambda language="E": (calls.update(lang=language) or {"indexed": 3})), |
| ) |
| assert cli._cmd_ingest_images(SimpleNamespace(source="web", language="S")) == 0 |
| assert calls["lang"] == "S" |
|
|
|
|
| def test_process_subtitles_forwards_remove_html(monkeypatch): |
| calls = {} |
| monkeypatch.setitem(sys.modules, "search", SimpleNamespace(get_search_instance=lambda: object())) |
| monkeypatch.setitem( |
| sys.modules, "processing_route_helpers", |
| SimpleNamespace( |
| process_subtitles_sync=lambda runtime, *, language, remove_html: ( |
| calls.update(r=(language, remove_html)) or {"processed": 1} |
| ) |
| ), |
| ) |
| assert cli._cmd_process_subtitles(SimpleNamespace(language="E", keep_html=False)) == 0 |
| assert calls["r"] == ("E", True) |
|
|
|
|
| def test_process_subtitles_translates_error_to_exit_code(monkeypatch): |
| monkeypatch.setitem(sys.modules, "search", SimpleNamespace(get_search_instance=lambda: object())) |
|
|
| def boom(runtime, *, language, remove_html): |
| raise RuntimeError("no subtitles dir") |
|
|
| monkeypatch.setitem(sys.modules, "processing_route_helpers", SimpleNamespace(process_subtitles_sync=boom)) |
| assert cli._cmd_process_subtitles(SimpleNamespace(language="E", keep_html=False)) == 1 |
|
|
|
|
| def test_reindex_embeddings_forwards_args(monkeypatch): |
| calls = {} |
| inst = SimpleNamespace( |
| rebuild_subtitle_embeddings=lambda language, batch_size: ( |
| calls.update(r=(language, batch_size)) or {"status": "success"} |
| ) |
| ) |
| monkeypatch.setitem(sys.modules, "search", SimpleNamespace(get_search_instance=lambda: inst)) |
| assert cli._cmd_reindex_embeddings(SimpleNamespace(language="E", batch_size=10)) == 0 |
| assert calls["r"] == ("E", 10) |
|
|
|
|
| def test_reindex_subtitles_forwards_args(monkeypatch): |
| calls = {} |
| inst = SimpleNamespace(rebuild_index=lambda language: (calls.update(r=language) or {"status": "success"})) |
| monkeypatch.setitem(sys.modules, "search", SimpleNamespace(get_search_instance=lambda: inst)) |
| assert cli._cmd_reindex_subtitles(SimpleNamespace(language=None)) == 0 |
| assert calls["r"] is None |
|
|
|
|
| def test_result_json_writes_result_dict_to_file(tmp_path, monkeypatch): |
| """--result-json writes the emitted result dict to a file (clean IPC for callers). |
| |
| Uses the ML-free `ingest video` validation error so no torch is imported. |
| """ |
| monkeypatch.setattr(cli, "ensure_runtime_dirs", lambda *_a, **_k: None) |
| monkeypatch.setattr(cli, "get_app_config", lambda: None) |
| out = tmp_path / "result.json" |
|
|
| |
| rc = cli.main(["--result-json", str(out), "ingest", "video"]) |
|
|
| assert rc == 1 |
| written = json.loads(out.read_text()) |
| assert written["status"] == "error" |
| assert "natural-key" in written["message"] |
|
|
|
|
| def test_result_json_absent_does_not_write_file(tmp_path, monkeypatch): |
| """Without --result-json, _emit only prints; it must not create a file.""" |
| monkeypatch.setattr(cli, "ensure_runtime_dirs", lambda *_a, **_k: None) |
| monkeypatch.setattr(cli, "get_app_config", lambda: None) |
| |
| cli._set_result_file(None) |
|
|
| rc = cli.main(["ingest", "video"]) |
|
|
| assert rc == 1 |
| assert not (tmp_path / "should_not_exist.json").exists() |
|
|
|
|
| def test_cli_import_and_parser_are_ml_free(): |
| """Importing cli + building the parser must not pull in torch/process_video.""" |
| code = ( |
| f"import sys; sys.path.insert(0, {BACKEND_DIR!r});" |
| "import cli; cli.build_parser();" |
| "assert 'torch' not in sys.modules, 'cli import pulled torch';" |
| "assert 'process_video' not in sys.modules, 'cli import pulled process_video';" |
| "print('ML-FREE-OK')" |
| ) |
| result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) |
| assert result.returncode == 0, result.stderr |
| assert "ML-FREE-OK" in result.stdout |
|
|