Files
bookshelf/tests/test_errors.py
night f29678ebf1 Initial commit
Photo-based book cataloger with AI identification.
Room → Cabinet → Shelf → Book hierarchy; FastAPI + SQLite backend;
vanilla JS SPA; OpenAI-compatible plugin system for boundary
detection, text recognition, and archive search.
2026-03-09 14:11:11 +03:00

191 lines
6.8 KiB
Python

"""Tests for config and image error conditions, and exception attribute contracts."""
from pathlib import Path
import pytest
from errors import (
ConfigFileError,
ConfigNotLoadedError,
ConfigValidationError,
ImageFileNotFoundError,
ImageReadError,
)
from logic.images import crop_save, prep_img_b64, serve_crop
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_png(tmp_path: Path, filename: str = "img.png") -> Path:
"""Write a minimal 4x4 red PNG to tmp_path and return its path."""
from PIL import Image
path = tmp_path / filename
img = Image.new("RGB", (4, 4), color=(255, 0, 0))
img.save(path, format="PNG")
return path
def _make_corrupt(tmp_path: Path, filename: str = "bad.jpg") -> Path:
"""Write a file with invalid image bytes and return its path."""
path = tmp_path / filename
path.write_bytes(b"this is not an image\xff\xfe")
return path
# ── ImageFileNotFoundError ────────────────────────────────────────────────────
def test_prep_img_b64_file_not_found(tmp_path: Path) -> None:
missing = tmp_path / "missing.png"
with pytest.raises(ImageFileNotFoundError) as exc_info:
prep_img_b64(missing)
assert exc_info.value.path == missing
assert str(missing) in str(exc_info.value)
def test_crop_save_file_not_found(tmp_path: Path) -> None:
missing = tmp_path / "missing.png"
with pytest.raises(ImageFileNotFoundError) as exc_info:
crop_save(missing, 0, 0, 2, 2)
assert exc_info.value.path == missing
def test_serve_crop_file_not_found(tmp_path: Path) -> None:
missing = tmp_path / "missing.png"
with pytest.raises(ImageFileNotFoundError) as exc_info:
serve_crop(missing, None)
assert exc_info.value.path == missing
# ── ImageReadError ────────────────────────────────────────────────────────────
def test_prep_img_b64_corrupt_file(tmp_path: Path) -> None:
bad = _make_corrupt(tmp_path)
with pytest.raises(ImageReadError) as exc_info:
prep_img_b64(bad)
assert exc_info.value.path == bad
assert str(bad) in str(exc_info.value)
assert exc_info.value.reason # non-empty reason
def test_crop_save_corrupt_file(tmp_path: Path) -> None:
bad = _make_corrupt(tmp_path)
with pytest.raises(ImageReadError) as exc_info:
crop_save(bad, 0, 0, 2, 2)
assert exc_info.value.path == bad
def test_serve_crop_corrupt_file(tmp_path: Path) -> None:
bad = _make_corrupt(tmp_path)
with pytest.raises(ImageReadError) as exc_info:
serve_crop(bad, None)
assert exc_info.value.path == bad
# ── prep_img_b64 success path ─────────────────────────────────────────────────
def test_prep_img_b64_success(tmp_path: Path) -> None:
path = _make_png(tmp_path)
b64, mime = prep_img_b64(path)
assert mime == "image/png"
assert len(b64) > 0
def test_prep_img_b64_with_crop(tmp_path: Path) -> None:
path = _make_png(tmp_path)
b64, mime = prep_img_b64(path, crop_frac=(0.0, 0.0, 0.5, 0.5))
assert mime == "image/png"
assert len(b64) > 0
# ── Config exception attribute contracts ──────────────────────────────────────
def test_config_not_loaded_error() -> None:
exc = ConfigNotLoadedError()
assert "load_config" in str(exc)
def test_config_file_error() -> None:
path = Path("config/missing.yaml")
exc = ConfigFileError(path, "file not found")
assert exc.path == path
assert exc.reason == "file not found"
assert "missing.yaml" in str(exc)
assert "file not found" in str(exc)
def test_config_validation_error() -> None:
exc = ConfigValidationError("unexpected field 'foo'")
assert exc.reason == "unexpected field 'foo'"
assert "unexpected field" in str(exc)
# ── Config loading errors ─────────────────────────────────────────────────────
def test_load_config_raises_on_invalid_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import config as config_module
cfg_dir = tmp_path / "config"
cfg_dir.mkdir()
(cfg_dir / "credentials.default.yaml").write_text(": invalid: yaml: {\n")
# write empty valid files for other categories
for cat in ["models", "functions", "ui"]:
(cfg_dir / f"{cat}.default.yaml").write_text(f"{cat}: {{}}\n")
monkeypatch.setattr(config_module, "_CONFIG_DIR", cfg_dir)
with pytest.raises(ConfigFileError) as exc_info:
config_module.load_config()
assert exc_info.value.path == cfg_dir / "credentials.default.yaml"
assert exc_info.value.reason
def test_load_config_raises_on_schema_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import config as config_module
cfg_dir = tmp_path / "config"
cfg_dir.mkdir()
# credentials expects CredentialConfig but we give it a non-dict value
(cfg_dir / "credentials.default.yaml").write_text("credentials:\n openrouter: not_a_dict\n")
for cat in ["models", "functions", "ui"]:
(cfg_dir / f"{cat}.default.yaml").write_text("")
monkeypatch.setattr(config_module, "_CONFIG_DIR", cfg_dir)
with pytest.raises(ConfigValidationError) as exc_info:
config_module.load_config()
assert exc_info.value.reason
def test_get_config_raises_if_not_loaded(monkeypatch: pytest.MonkeyPatch) -> None:
import config as config_module
# Clear the holder to simulate unloaded state
original = list(config_module.config_holder)
config_module.config_holder.clear()
try:
with pytest.raises(ConfigNotLoadedError):
config_module.get_config()
finally:
config_module.config_holder.extend(original)
# ── Image exception string representation ─────────────────────────────────────
def test_image_file_not_found_str() -> None:
exc = ImageFileNotFoundError(Path("/data/images/img.jpg"))
assert exc.path == Path("/data/images/img.jpg")
assert "img.jpg" in str(exc)
def test_image_read_error_str() -> None:
exc = ImageReadError(Path("/data/images/img.jpg"), "cannot identify image file")
assert exc.path == Path("/data/images/img.jpg")
assert exc.reason == "cannot identify image file"
assert "img.jpg" in str(exc)
assert "cannot identify image file" in str(exc)