Files
photoanalyzer/tests/integration/test_inventory_reconcile.py

213 lines
6.7 KiB
Python

"""Reconciliation: stable identity across move/copy/replace/remove, with
idempotent rescans and durable state after a restart.
Fixtures live here rather than in a sibling conftest.py: the characterization
suite imports its own ``conftest`` by bare name, and a second sibling conftest
would shadow it under pytest's prepend import mode.
"""
import shutil
import numpy as np
import pytest
from PIL import Image
from sqlalchemy import select
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import Asset, AssetPath
from photo_pipeline.services.inventory import InventoryService, Occurrence
@pytest.fixture
def db_url(tmp_path):
url = f"sqlite:///{tmp_path / 'inv.db'}"
run_migrations(url)
return url
@pytest.fixture
def make_factory():
engines = []
def _make(url):
engine = create_db_engine(url)
engines.append(engine)
return create_session_factory(engine)
yield _make
for engine in engines:
engine.dispose()
@pytest.fixture
def inventory(db_url, make_factory):
return InventoryService(make_factory(db_url))
@pytest.fixture
def make_image():
def _make(path, seed=1, size=(64, 48)):
path.parent.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(seed)
arr = rng.integers(0, 256, (size[1], size[0], 3), dtype=np.uint8)
Image.fromarray(arr).save(path, quality=95)
return path
return _make
@pytest.fixture
def assets_by_id():
def _snapshot(factory):
with factory() as session:
return {a.id: a for a in session.execute(select(Asset)).scalars()}
return _snapshot
def test_initial_scan_registers_new_assets(tmp_path, inventory, make_image):
lib = tmp_path / "lib"
make_image(lib / "a.jpg", seed=1)
make_image(lib / "album" / "b.jpg", seed=2)
result = inventory.scan(lib)
assert result.counts.get(Occurrence.NEW.value) == 2
assert set(result.occurrences.values()) == {Occurrence.NEW.value}
assert len(result.asset_ids) == 2
def test_rescan_unchanged_is_idempotent(
tmp_path, inventory, db_url, make_factory, make_image, assets_by_id
):
lib = tmp_path / "lib"
make_image(lib / "a.jpg", seed=1)
make_image(lib / "b.jpg", seed=2)
first = inventory.scan(lib)
before = assets_by_id(make_factory(db_url))
second = inventory.scan(lib)
after = assets_by_id(make_factory(db_url))
assert set(second.occurrences.values()) == {Occurrence.UNCHANGED.value}
assert set(before) == set(after) # same asset ids, none created
assert first.asset_ids == second.asset_ids
def test_move_preserves_identity(tmp_path, inventory, db_url, make_factory, make_image):
lib = tmp_path / "lib"
src = make_image(lib / "a.jpg", seed=1)
original_id = next(iter(inventory.scan(lib).asset_ids.values()))
dst = lib / "album" / "moved.jpg"
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dst))
result = inventory.scan(lib)
assert result.occurrences[str(dst)] == Occurrence.MOVED.value
assert result.asset_ids[str(dst)] == original_id # id retained across the move
# Path history: old occurrence closed, new occurrence open.
with make_factory(db_url)() as session:
paths = session.query(AssetPath).filter_by(asset_id=original_id).all()
by_path = {p.path: p for p in paths}
assert by_path[str(src)].valid_until is not None
assert by_path[str(dst)].valid_until is None
def test_copy_creates_distinct_identity(tmp_path, inventory, make_image):
lib = tmp_path / "lib"
src = make_image(lib / "a.jpg", seed=1)
first = inventory.scan(lib)
original_id = first.asset_ids[str(src)]
copy = lib / "a_copy.jpg"
shutil.copy2(str(src), str(copy))
result = inventory.scan(lib)
assert result.occurrences[str(copy)] == Occurrence.COPIED.value
assert result.asset_ids[str(copy)] != original_id
assert result.asset_ids[str(src)] == original_id
def test_replace_versus_metadata_change(tmp_path, inventory, make_image):
lib = tmp_path / "lib"
same = make_image(lib / "same.jpg", seed=1)
other = make_image(lib / "other.jpg", seed=2)
ids = inventory.scan(lib).asset_ids
# Metadata-style edit: append trailing bytes (pixels unchanged).
with open(same, "ab") as handle:
handle.write(b"\xff\xfe trailing")
# Content replacement: overwrite with different pixels.
make_image(other, seed=99)
result = inventory.scan(lib)
assert result.occurrences[str(same)] == Occurrence.METADATA_CHANGED.value
assert result.occurrences[str(other)] == Occurrence.REPLACED.value
# Identity survives both; hashes refreshed.
assert result.asset_ids[str(same)] == ids[str(same)]
assert result.asset_ids[str(other)] == ids[str(other)]
def test_missing_file_is_flagged_not_deleted(
tmp_path, inventory, db_url, make_factory, make_image, assets_by_id
):
lib = tmp_path / "lib"
a = make_image(lib / "a.jpg", seed=1)
make_image(lib / "b.jpg", seed=2)
ids = inventory.scan(lib).asset_ids
missing_id = ids[str(a)]
a.unlink()
result = inventory.scan(lib)
assert result.occurrences[str(a)] == Occurrence.MISSING.value
assets = assets_by_id(make_factory(db_url))
assert missing_id in assets # not pruned
assert assets[missing_id].missing_at is not None
assert assets[missing_id].availability_state == "active"
def test_reappearing_file_clears_missing(
tmp_path, inventory, db_url, make_factory, make_image, assets_by_id
):
lib = tmp_path / "lib"
a = make_image(lib / "a.jpg", seed=1)
ids = inventory.scan(lib).asset_ids
asset_id = ids[str(a)]
backup = tmp_path / "backup.jpg"
shutil.move(str(a), str(backup))
inventory.scan(lib) # marks missing
shutil.move(str(backup), str(a)) # reappears at the same path
inventory.scan(lib)
assets = assets_by_id(make_factory(db_url))
assert assets[asset_id].missing_at is None
def test_identity_and_state_durable_across_restart(
tmp_path, db_url, make_factory, make_image, assets_by_id
):
from photo_pipeline.services.inventory import InventoryService
lib = tmp_path / "lib"
src = make_image(lib / "a.jpg", seed=1)
service_a = InventoryService(make_factory(db_url))
original_id = service_a.scan(lib).asset_ids[str(src)]
# "Restart": a brand-new service/session factory against the same database.
dst = lib / "renamed.jpg"
shutil.move(str(src), str(dst))
service_b = InventoryService(make_factory(db_url))
result = service_b.scan(lib)
assert result.asset_ids[str(dst)] == original_id
assert result.occurrences[str(dst)] == Occurrence.MOVED.value
assets = assets_by_id(make_factory(db_url))
assert assets[original_id].current_path == str(dst)