Files
MajoorWaldi df96cac6ac feat(settings): add verbose logging options for route registration and startup
- Add new API endpoints for route logging settings
- Implement new settings in the advanced settings page
- Update installation instructions to reflect the correct WinGet package ID for ExifTool
2026-04-06 18:07:19 +02:00

316 lines
11 KiB
Python

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