import json import subprocess from pathlib import Path import pytest from mjr_am_backend.adapters.tools import exiftool as m from mjr_am_backend.shared import ErrorCode, Result def _mk_completed(returncode=0, stdout=b"", stderr=b""): return subprocess.CompletedProcess(args=["x"], returncode=returncode, stdout=stdout, stderr=stderr) def test_decode_bytes_best_effort_variants(): assert m._decode_bytes_best_effort(None) == ("", False) assert m._decode_bytes_best_effort("x") == ("x", False) assert m._decode_bytes_best_effort(b"abc")[0] == "abc" def test_tag_validation_helpers(): assert m._is_safe_exiftool_tag("XMP:Rating") is True assert m._is_safe_exiftool_tag("-bad") is False assert m._is_safe_exiftool_tag("bad tag") is False ok = m._validate_exiftool_tags(["XMP:Rating", "EXIF:Artist"]) assert ok.ok and len(ok.data) == 2 bad = m._validate_exiftool_tags(["bad tag"]) assert bad.code == ErrorCode.INVALID_INPUT def test_path_match_helpers(tmp_path: Path): p = tmp_path / "a.png" p.write_text("x") key = m._normalize_match_path(str(p)) assert key key2 = m._normalize_match_path("\x00") assert key2 is None mp, cmd = m._build_match_map([str(p), str(p)]) assert len(cmd) == 1 assert len(next(iter(mp.values()))) == 2 @pytest.fixture def ex(monkeypatch): monkeypatch.setattr(m.ExifTool, "_check_available", lambda self: True) return m.ExifTool(bin_name="exiftool", timeout=1.0) def test_executable_resolution_helpers(monkeypatch, tmp_path: Path): exep = tmp_path / "exiftool.exe" exep.write_text("x") monkeypatch.setattr(m.shutil, "which", lambda raw: str(exep) if raw == "exiftool" else None) assert m.ExifTool._is_safe_executable_name("exiftool") is True assert m.ExifTool._is_safe_executable_name("bad|x") is False assert m.ExifTool._resolve_executable_path("exiftool") == str(exep) assert m.ExifTool._resolve_executable_path('"exiftool"') == str(exep) assert m.ExifTool._looks_like_exiftool_name(str(exep)) is True assert m.ExifTool._looks_like_exiftool_name(str(tmp_path / "exiftool_malicious.exe")) is False def test_resolve_executable_path_uses_windows_alias_sibling(monkeypatch, tmp_path: Path): exep = tmp_path / "exiftool(-k).exe" exep.write_text("x") monkeypatch.setattr(m.shutil, "which", lambda _raw: None) configured = tmp_path / "exiftool" assert m.ExifTool._resolve_executable_path(str(configured)) == str(exep) def test_resolve_executable_path_uses_common_windows_install_dir(monkeypatch, tmp_path: Path): exep = tmp_path / "Programs" / "ExifTool" / "exiftool.exe" exep.parent.mkdir(parents=True) exep.write_text("x") monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) monkeypatch.setattr(m.shutil, "which", lambda _raw: None) assert m.ExifTool._resolve_executable_path("exiftool") == str(exep.resolve()) def test_trusted_roots_and_under_trusted_dirs(monkeypatch, tmp_path: Path): d = tmp_path / "bin" d.mkdir() f = d / "exiftool.exe" f.write_text("x") monkeypatch.setenv("MAJOOR_EXIFTOOL_TRUSTED_DIRS", str(d)) assert m.ExifTool._trusted_roots(str(d)) assert m.ExifTool._is_under_trusted_dirs(str(f)) is True def test_collect_valid_batch_paths_and_validate_tags(ex, tmp_path: Path): p = tmp_path / "a.png" p.write_text("x") valid, results = ex._collect_valid_batch_paths([str(p), "", str(tmp_path / "missing")]) assert valid == [str(p)] assert len(results) == 2 safe = ex._validate_batch_tags(["XMP:Rating"], valid, results) assert safe == ["XMP:Rating"] safe2 = ex._validate_batch_tags(["bad tag"], valid, results) assert safe2 is None def test_build_batch_command_windows(ex, monkeypatch): monkeypatch.setattr(m.os, "name", "nt") cmd, stdin_input, timeout_s = ex._build_batch_command(["a.png"], []) assert "-@" in cmd and isinstance(stdin_input, (bytes, bytearray)) assert timeout_s == ex.timeout def test_build_batch_command_non_windows(ex, monkeypatch): monkeypatch.setattr(m.os, "name", "posix") cmd, stdin_input, timeout_s = ex._build_batch_command(["a.png"], ["XMP:Rating"]) assert "-XMP:Rating" in cmd assert stdin_input is None assert timeout_s == ex.timeout def test_parse_batch_output_paths(ex): results = {} valid = ["a"] data, rep = ex._parse_batch_output(_mk_completed(0, stdout=b'[{"SourceFile":"a"}]', stderr=b""), valid, results) assert isinstance(data, list) and rep is False data2, _ = ex._parse_batch_output(_mk_completed(1, stdout=b"", stderr=b"err"), valid, results) assert data2 is None data3, _ = ex._parse_batch_output(_mk_completed(0, stdout=b"{}", stderr=b""), valid, results) assert data3 is None def test_map_and_fill_missing_batch_results(): results = {} key = m._normalize_match_path("a") or "a" m.ExifTool._map_batch_results([{"SourceFile": "a", "k": 1}], {key: ["a"]}, results, had_replacements=False) assert results["a"].ok m.ExifTool._fill_missing_batch_results(["a", "b"], results) assert "b" in results and not results["b"].ok def test_handle_batch_exception_winerror_fallback(ex, monkeypatch): called = [] monkeypatch.setattr(ex, "read", lambda p, t: (called.append((p, t)), Result.Ok({"x": 1}))[1]) err = OSError("x") err.winerror = 206 out = ex._handle_batch_exception(err, ["a", "b"], {}, ["Tag"]) assert set(out.keys()) == {"a", "b"} assert len(called) == 2 def test_validate_single_read_helpers(tmp_path: Path): p = tmp_path / "a.png" p.write_text("x") ok = m.ExifTool._validate_single_read_path(str(p)) assert ok.ok bad = m.ExifTool._validate_single_read_path("") assert bad.code == ErrorCode.INVALID_INPUT ok2 = m.ExifTool._validate_single_read_tags(["XMP:Rating"]) assert ok2.ok bad2 = m.ExifTool._validate_single_read_tags(["bad tag"]) assert bad2.code == ErrorCode.INVALID_INPUT def test_retry_windows_single_read_file_not_found(ex, monkeypatch): monkeypatch.setattr(m.os, "name", "nt") first = _mk_completed(1, b"", b"File not found") second = _mk_completed(0, b'[{"x":1}]', b"") calls = {"n": 0} def _run(*_a, **_k): calls["n"] += 1 return second monkeypatch.setattr(ex, "_run_exiftool_process", _run) out = ex._retry_windows_single_read_file_not_found(first, "a.png", []) assert out.returncode == 0 and calls["n"] == 1 def test_parse_single_read_process_branches(): r1 = m.ExifTool._parse_single_read_process(_mk_completed(1, b"", b"err"), "a") assert r1.code == ErrorCode.EXIFTOOL_ERROR r2 = m.ExifTool._parse_single_read_process(_mk_completed(0, b"", b""), "a") assert r2.code == ErrorCode.PARSE_ERROR r3 = m.ExifTool._parse_single_read_process(_mk_completed(0, b"not-json", b""), "a") assert r3.code == ErrorCode.PARSE_ERROR r4 = m.ExifTool._parse_single_read_process(_mk_completed(0, b"[]", b""), "a") assert r4.code == ErrorCode.PARSE_ERROR r5 = m.ExifTool._parse_single_read_process(_mk_completed(0, b'[{"a":1}]', b""), "a") assert r5.ok def test_prepare_single_read_inputs_and_read(ex, tmp_path: Path, monkeypatch): p = tmp_path / "a.png" p.write_text("x") monkeypatch.setattr(ex, "_run_single_read_process", lambda path, safe: _mk_completed(0, b'[{"x":1}]', b"")) out = ex.read(str(p), ["XMP:Rating"]) assert out.ok out2 = ex.read("", None) assert out2.code == ErrorCode.INVALID_INPUT def test_read_handles_timeout_and_exception(ex, tmp_path: Path, monkeypatch): p = tmp_path / "a.png" p.write_text("x") def _to(*_a, **_k): raise subprocess.TimeoutExpired(cmd="x", timeout=1) monkeypatch.setattr(ex, "_run_single_read_process", _to) out = ex.read(str(p), None) assert out.code == ErrorCode.TIMEOUT monkeypatch.setattr(ex, "_run_single_read_process", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom"))) out2 = ex.read(str(p), None) assert out2.code == ErrorCode.EXIFTOOL_ERROR def test_read_batch_main_paths(ex, tmp_path: Path, monkeypatch): p = tmp_path / "a.png" p.write_text("x") payload = json.dumps([{"SourceFile": str(p), "x": 1}]).encode() monkeypatch.setattr(ex, "_run_batch_subprocess", lambda *_a, **_k: _mk_completed(0, payload, b"")) out = ex.read_batch([str(p)], ["XMP:Rating"]) assert out[str(p)].ok def test_read_batch_timeout_and_unexpected(ex, tmp_path: Path, monkeypatch): p = tmp_path / "a.png" p.write_text("x") def _timeout(*_a, **_k): raise subprocess.TimeoutExpired(cmd="x", timeout=1) monkeypatch.setattr(ex, "_run_batch_subprocess", _timeout) out = ex.read_batch([str(p)], None) assert out[str(p)].code == ErrorCode.TIMEOUT monkeypatch.setattr(ex, "_run_batch_subprocess", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom"))) out2 = ex.read_batch([str(p)], None) assert out2[str(p)].code in {ErrorCode.EXIFTOOL_ERROR, ErrorCode.PARSE_ERROR} def test_write_helpers_and_write_flow(ex, tmp_path: Path, monkeypatch): p = tmp_path / "a.png" p.write_text("x") assert ex._validate_write_preconditions("") is not None assert ex._invalid_write_keys({"XMP:Rating": 5, "bad tag": 1}) == ["bad tag"] cmd, stdin_input = ex._build_write_command(str(p), {"XMP:Rating": 5, "XMP:Keywords": ["a", None, "b"]}, True) assert "-tagsFromFile" in cmd assert isinstance(stdin_input, (str, type(None))) monkeypatch.setattr(ex, "_run_write_command", lambda *_a, **_k: _mk_completed(0, b"", b"")) out = ex.write(str(p), {"XMP:Rating": 5}) assert out.ok monkeypatch.setattr(ex, "_run_write_command", lambda *_a, **_k: _mk_completed(1, b"", b"err")) out2 = ex.write(str(p), {"XMP:Rating": 5}) assert out2.code == ErrorCode.EXIFTOOL_ERROR def test_write_errors_and_tool_missing(tmp_path: Path, monkeypatch): monkeypatch.setattr(m.ExifTool, "_check_available", lambda self: False) ex = m.ExifTool() out = ex.write("x", {}) assert out.code == ErrorCode.TOOL_MISSING monkeypatch.setattr(m.ExifTool, "_check_available", lambda self: True) ex2 = m.ExifTool(timeout=0.1) p = tmp_path / "a.png" p.write_text("x") monkeypatch.setattr(ex2, "_run_write_command", lambda *_a, **_k: (_ for _ in ()).throw(subprocess.TimeoutExpired(cmd="x", timeout=1))) out2 = ex2.write(str(p), {"XMP:Rating": 5}) assert out2.code == ErrorCode.TIMEOUT def test_is_available_rechecks_when_tool_appears(monkeypatch): calls = {"n": 0} def _check(self): calls["n"] += 1 return calls["n"] >= 2 monkeypatch.setattr(m.ExifTool, "_check_available", _check) ex = m.ExifTool() assert ex.is_available() is True assert calls["n"] == 2 @pytest.mark.asyncio async def test_async_wrappers(ex, tmp_path: Path, monkeypatch): p = tmp_path / "a.png" p.write_text("x") monkeypatch.setattr(ex, "read", lambda path, tags=None: Result.Ok({"p": path, "tags": tags})) monkeypatch.setattr(ex, "read_batch", lambda paths, tags=None: {paths[0]: Result.Ok({"tags": tags})}) monkeypatch.setattr(ex, "write", lambda path, metadata, preserve_workflow=True: Result.Ok(True)) r = await ex.aread(str(p), ["XMP:Rating"]) assert r.ok b = await ex.aread_batch([str(p)], ["XMP:Rating"]) assert b[str(p)].ok w = await ex.awrite(str(p), {"XMP:Rating": 5}) assert w.ok