diff --git a/app/assets/api/routes.py b/app/assets/api/routes.py index 7c27cdfea..fdddc653d 100644 --- a/app/assets/api/routes.py +++ b/app/assets/api/routes.py @@ -2,7 +2,6 @@ import logging import uuid import urllib.parse import os -import contextlib from aiohttp import web from pydantic import ValidationError @@ -20,7 +19,6 @@ from app.assets.api.upload import parse_multipart_upload from app.assets.services.scanner import seed_assets from typing import Any -import folder_paths ROUTES = web.RouteTableDef() USER_MANAGER: user_manager.UserManager | None = None diff --git a/app/assets/api/upload.py b/app/assets/api/upload.py index 3be3e9cec..91d67acff 100644 --- a/app/assets/api/upload.py +++ b/app/assets/api/upload.py @@ -16,7 +16,7 @@ from app.assets.api.schemas_in import ParsedUpload, UploadError def validate_hash_format(s: str) -> str: """ Validate and normalize a hash string. - + Returns canonical 'blake3:' or raises UploadError. """ s = s.strip().lower() diff --git a/app/assets/database/queries/asset.py b/app/assets/database/queries/asset.py index ad8a98dd1..7a14f15c8 100644 --- a/app/assets/database/queries/asset.py +++ b/app/assets/database/queries/asset.py @@ -1,4 +1,3 @@ -from typing import Iterable import sqlalchemy as sa from sqlalchemy import select diff --git a/app/assets/database/queries/asset_info.py b/app/assets/database/queries/asset_info.py index ff2350ba6..c523059c3 100644 --- a/app/assets/database/queries/asset_info.py +++ b/app/assets/database/queries/asset_info.py @@ -1,7 +1,7 @@ """ Pure atomic database queries for AssetInfo operations. -This module contains only atomic DB operations - no business logic, +This module contains only atomic DB operations - no business logic, no filesystem operations, no orchestration across multiple tables. """ from collections import defaultdict @@ -246,7 +246,7 @@ def get_or_create_asset_info( ) if info: return info, True - + existing = session.execute( select(AssetInfo) .where( diff --git a/app/assets/manager.py b/app/assets/manager.py index efb94af71..63fe4acde 100644 --- a/app/assets/manager.py +++ b/app/assets/manager.py @@ -28,9 +28,7 @@ from app.assets.api.upload import _cleanup_temp from app.assets.database.queries import ( asset_exists_by_hash, fetch_asset_info_and_asset, - fetch_asset_info_asset_and_tags, get_asset_by_hash, - get_asset_info_by_id, get_asset_tags, list_asset_infos_page, list_cache_states_by_asset_id, @@ -417,7 +415,7 @@ def set_asset_preview( owner_id=owner_id, ) info = result["info"] - asset = result["asset"]T + asset = result["asset"] tags = result["tags"] return schemas_out.AssetDetail( diff --git a/app/assets/services/asset_management.py b/app/assets/services/asset_management.py index bba6eb833..2a4ce55aa 100644 --- a/app/assets/services/asset_management.py +++ b/app/assets/services/asset_management.py @@ -18,7 +18,6 @@ from app.assets.services.path_utils import compute_relative_filename from app.assets.database.queries import ( asset_info_exists_for_asset_id, delete_asset_info_by_id, - fetch_asset_info_and_asset, fetch_asset_info_asset_and_tags, get_asset_info_by_id, list_cache_states_by_asset_id, diff --git a/app/assets/services/ingest.py b/app/assets/services/ingest.py index ff9dcf11c..f4a059bd6 100644 --- a/app/assets/services/ingest.py +++ b/app/assets/services/ingest.py @@ -13,7 +13,7 @@ from sqlalchemy import select from app.assets.database.models import Asset, Tag from app.database.db import create_session -from app.assets.helpers import normalize_tags, pick_best_live_path, utcnow +from app.assets.helpers import normalize_tags, pick_best_live_path from app.assets.services.path_utils import compute_relative_filename from app.assets.database.queries import ( get_asset_by_hash, @@ -26,7 +26,6 @@ from app.assets.database.queries import ( upsert_asset, upsert_cache_state, add_tags_to_asset_info, - ensure_tags_exist, get_asset_tags, ) @@ -147,7 +146,7 @@ def register_existing_asset( ) -> dict: """ Create or return existing AssetInfo for an asset that already exists by hash. - + Returns dict with asset and info details, or raises ValueError if hash not found. """ with create_session() as session: diff --git a/tests-unit/assets_test/queries/conftest.py b/tests-unit/assets_test/queries/conftest.py index 6e05031db..4ca0e86a9 100644 --- a/tests-unit/assets_test/queries/conftest.py +++ b/tests-unit/assets_test/queries/conftest.py @@ -12,3 +12,9 @@ def session(): Base.metadata.create_all(engine) with Session(engine) as sess: yield sess + + +@pytest.fixture(autouse=True) +def autoclean_unit_test_assets(): + """Override parent autouse fixture - query tests don't need server cleanup.""" + yield diff --git a/tests-unit/assets_test/queries/test_asset.py b/tests-unit/assets_test/queries/test_asset.py index 432910435..8fea1cb17 100644 --- a/tests-unit/assets_test/queries/test_asset.py +++ b/tests-unit/assets_test/queries/test_asset.py @@ -1,7 +1,13 @@ +import uuid from sqlalchemy.orm import Session from app.assets.database.models import Asset -from app.assets.database.queries import asset_exists_by_hash, get_asset_by_hash +from app.assets.database.queries import ( + asset_exists_by_hash, + get_asset_by_hash, + upsert_asset, + bulk_insert_assets, +) class TestAssetExistsByHash: @@ -37,3 +43,121 @@ class TestGetAssetByHash: assert result.id == asset.id assert result.size_bytes == 200 assert result.mime_type == "image/png" + + +class TestUpsertAsset: + def test_creates_new_asset(self, session: Session): + asset, created, updated = upsert_asset( + session, + asset_hash="blake3:newasset", + size_bytes=1024, + mime_type="application/octet-stream", + ) + session.commit() + + assert created is True + assert updated is False + assert asset.hash == "blake3:newasset" + assert asset.size_bytes == 1024 + assert asset.mime_type == "application/octet-stream" + + def test_returns_existing_asset_without_update(self, session: Session): + # First insert + asset1, created1, _ = upsert_asset( + session, + asset_hash="blake3:existing", + size_bytes=500, + mime_type="text/plain", + ) + session.commit() + + # Second upsert with same values + asset2, created2, updated2 = upsert_asset( + session, + asset_hash="blake3:existing", + size_bytes=500, + mime_type="text/plain", + ) + session.commit() + + assert created1 is True + assert created2 is False + assert updated2 is False + assert asset1.id == asset2.id + + def test_updates_existing_asset_with_new_values(self, session: Session): + # First insert with size 0 + asset1, created1, _ = upsert_asset( + session, + asset_hash="blake3:toupdate", + size_bytes=0, + ) + session.commit() + + # Second upsert with new size and mime type + asset2, created2, updated2 = upsert_asset( + session, + asset_hash="blake3:toupdate", + size_bytes=2048, + mime_type="image/png", + ) + session.commit() + + assert created1 is True + assert created2 is False + assert updated2 is True + assert asset2.size_bytes == 2048 + assert asset2.mime_type == "image/png" + + def test_does_not_update_if_size_zero(self, session: Session): + # First insert + asset1, _, _ = upsert_asset( + session, + asset_hash="blake3:keepsize", + size_bytes=1000, + ) + session.commit() + + # Second upsert with size 0 should not change size + asset2, created2, updated2 = upsert_asset( + session, + asset_hash="blake3:keepsize", + size_bytes=0, + ) + session.commit() + + assert created2 is False + assert updated2 is False + assert asset2.size_bytes == 1000 + + +class TestBulkInsertAssets: + def test_inserts_multiple_assets(self, session: Session): + rows = [ + {"id": str(uuid.uuid4()), "hash": "blake3:bulk1", "size_bytes": 100, "mime_type": "text/plain", "created_at": None}, + {"id": str(uuid.uuid4()), "hash": "blake3:bulk2", "size_bytes": 200, "mime_type": "image/png", "created_at": None}, + {"id": str(uuid.uuid4()), "hash": "blake3:bulk3", "size_bytes": 300, "mime_type": None, "created_at": None}, + ] + bulk_insert_assets(session, rows) + session.commit() + + assets = session.query(Asset).all() + assert len(assets) == 3 + hashes = {a.hash for a in assets} + assert hashes == {"blake3:bulk1", "blake3:bulk2", "blake3:bulk3"} + + def test_empty_list_is_noop(self, session: Session): + bulk_insert_assets(session, []) + session.commit() + assert session.query(Asset).count() == 0 + + def test_handles_large_batch(self, session: Session): + """Test chunking logic with more rows than MAX_BIND_PARAMS allows.""" + rows = [ + {"id": str(uuid.uuid4()), "hash": f"blake3:large{i}", "size_bytes": i, "mime_type": None, "created_at": None} + for i in range(200) + ] + bulk_insert_assets(session, rows) + session.commit() + + assert session.query(Asset).count() == 200 diff --git a/tests-unit/assets_test/queries/test_asset_info.py b/tests-unit/assets_test/queries/test_asset_info.py index fdcbd489e..981a0b297 100644 --- a/tests-unit/assets_test/queries/test_asset_info.py +++ b/tests-unit/assets_test/queries/test_asset_info.py @@ -1,16 +1,24 @@ +import time +import uuid import pytest from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetInfo +from app.assets.database.models import Asset, AssetInfo, AssetInfoMeta from app.assets.database.queries import ( asset_info_exists_for_asset_id, get_asset_info_by_id, + insert_asset_info, + get_or_create_asset_info, + update_asset_info_timestamps, list_asset_infos_page, fetch_asset_info_asset_and_tags, fetch_asset_info_and_asset, touch_asset_info_by_id, + replace_asset_info_metadata_projection, delete_asset_info_by_id, set_asset_info_preview, + bulk_insert_asset_infos_ignore_conflicts, + get_asset_info_ids_by_ids, ensure_tags_exist, add_tags_to_asset_info, ) @@ -266,3 +274,238 @@ class TestSetAssetInfoPreview: with pytest.raises(ValueError, match="Preview Asset"): set_asset_info_preview(session, asset_info_id=info.id, preview_asset_id="nonexistent") + + +class TestInsertAssetInfo: + def test_creates_new_info(self, session: Session): + asset = _make_asset(session, "hash1") + info = insert_asset_info( + session, asset_id=asset.id, owner_id="user1", name="test.bin" + ) + session.commit() + + assert info is not None + assert info.name == "test.bin" + assert info.owner_id == "user1" + + def test_returns_none_on_conflict(self, session: Session): + asset = _make_asset(session, "hash1") + insert_asset_info(session, asset_id=asset.id, owner_id="user1", name="dup.bin") + session.commit() + + # Attempt duplicate with same (asset_id, owner_id, name) + result = insert_asset_info( + session, asset_id=asset.id, owner_id="user1", name="dup.bin" + ) + assert result is None + + +class TestGetOrCreateAssetInfo: + def test_creates_new_info(self, session: Session): + asset = _make_asset(session, "hash1") + info, created = get_or_create_asset_info( + session, asset_id=asset.id, owner_id="user1", name="new.bin" + ) + session.commit() + + assert created is True + assert info.name == "new.bin" + + def test_returns_existing_info(self, session: Session): + asset = _make_asset(session, "hash1") + info1, created1 = get_or_create_asset_info( + session, asset_id=asset.id, owner_id="user1", name="existing.bin" + ) + session.commit() + + info2, created2 = get_or_create_asset_info( + session, asset_id=asset.id, owner_id="user1", name="existing.bin" + ) + session.commit() + + assert created1 is True + assert created2 is False + assert info1.id == info2.id + + +class TestUpdateAssetInfoTimestamps: + def test_updates_timestamps(self, session: Session): + asset = _make_asset(session, "hash1") + info = _make_asset_info(session, asset) + original_updated_at = info.updated_at + session.commit() + + time.sleep(0.01) + update_asset_info_timestamps(session, info) + session.commit() + + session.refresh(info) + assert info.updated_at > original_updated_at + + def test_updates_preview_id(self, session: Session): + asset = _make_asset(session, "hash1") + preview_asset = _make_asset(session, "preview_hash") + info = _make_asset_info(session, asset) + session.commit() + + update_asset_info_timestamps(session, info, preview_id=preview_asset.id) + session.commit() + + session.refresh(info) + assert info.preview_id == preview_asset.id + + +class TestReplaceAssetInfoMetadataProjection: + def test_sets_metadata(self, session: Session): + asset = _make_asset(session, "hash1") + info = _make_asset_info(session, asset) + session.commit() + + replace_asset_info_metadata_projection( + session, asset_info_id=info.id, user_metadata={"key": "value"} + ) + session.commit() + + session.refresh(info) + assert info.user_metadata == {"key": "value"} + # Check metadata table + meta = session.query(AssetInfoMeta).filter_by(asset_info_id=info.id).all() + assert len(meta) == 1 + assert meta[0].key == "key" + assert meta[0].val_str == "value" + + def test_replaces_existing_metadata(self, session: Session): + asset = _make_asset(session, "hash1") + info = _make_asset_info(session, asset) + session.commit() + + replace_asset_info_metadata_projection( + session, asset_info_id=info.id, user_metadata={"old": "data"} + ) + session.commit() + + replace_asset_info_metadata_projection( + session, asset_info_id=info.id, user_metadata={"new": "data"} + ) + session.commit() + + meta = session.query(AssetInfoMeta).filter_by(asset_info_id=info.id).all() + assert len(meta) == 1 + assert meta[0].key == "new" + + def test_clears_metadata_with_empty_dict(self, session: Session): + asset = _make_asset(session, "hash1") + info = _make_asset_info(session, asset) + session.commit() + + replace_asset_info_metadata_projection( + session, asset_info_id=info.id, user_metadata={"key": "value"} + ) + session.commit() + + replace_asset_info_metadata_projection( + session, asset_info_id=info.id, user_metadata={} + ) + session.commit() + + session.refresh(info) + assert info.user_metadata == {} + meta = session.query(AssetInfoMeta).filter_by(asset_info_id=info.id).all() + assert len(meta) == 0 + + def test_raises_for_nonexistent(self, session: Session): + with pytest.raises(ValueError, match="not found"): + replace_asset_info_metadata_projection( + session, asset_info_id="nonexistent", user_metadata={"key": "value"} + ) + + +class TestBulkInsertAssetInfosIgnoreConflicts: + def test_inserts_multiple_infos(self, session: Session): + asset = _make_asset(session, "hash1") + now = utcnow() + rows = [ + { + "id": str(uuid.uuid4()), + "owner_id": "", + "name": "bulk1.bin", + "asset_id": asset.id, + "preview_id": None, + "user_metadata": {}, + "created_at": now, + "updated_at": now, + "last_access_time": now, + }, + { + "id": str(uuid.uuid4()), + "owner_id": "", + "name": "bulk2.bin", + "asset_id": asset.id, + "preview_id": None, + "user_metadata": {}, + "created_at": now, + "updated_at": now, + "last_access_time": now, + }, + ] + bulk_insert_asset_infos_ignore_conflicts(session, rows) + session.commit() + + infos = session.query(AssetInfo).all() + assert len(infos) == 2 + + def test_ignores_conflicts(self, session: Session): + asset = _make_asset(session, "hash1") + _make_asset_info(session, asset, name="existing.bin", owner_id="") + session.commit() + + now = utcnow() + rows = [ + { + "id": str(uuid.uuid4()), + "owner_id": "", + "name": "existing.bin", + "asset_id": asset.id, + "preview_id": None, + "user_metadata": {}, + "created_at": now, + "updated_at": now, + "last_access_time": now, + }, + { + "id": str(uuid.uuid4()), + "owner_id": "", + "name": "new.bin", + "asset_id": asset.id, + "preview_id": None, + "user_metadata": {}, + "created_at": now, + "updated_at": now, + "last_access_time": now, + }, + ] + bulk_insert_asset_infos_ignore_conflicts(session, rows) + session.commit() + + infos = session.query(AssetInfo).all() + assert len(infos) == 2 # existing + new, not 3 + + def test_empty_list_is_noop(self, session: Session): + bulk_insert_asset_infos_ignore_conflicts(session, []) + assert session.query(AssetInfo).count() == 0 + + +class TestGetAssetInfoIdsByIds: + def test_returns_existing_ids(self, session: Session): + asset = _make_asset(session, "hash1") + info1 = _make_asset_info(session, asset, name="a.bin") + info2 = _make_asset_info(session, asset, name="b.bin") + session.commit() + + found = get_asset_info_ids_by_ids(session, [info1.id, info2.id, "nonexistent"]) + + assert found == {info1.id, info2.id} + + def test_empty_list_returns_empty(self, session: Session): + found = get_asset_info_ids_by_ids(session, []) + assert found == set() diff --git a/tests-unit/assets_test/queries/test_cache_state.py b/tests-unit/assets_test/queries/test_cache_state.py index bd5ea4a92..3068db1ec 100644 --- a/tests-unit/assets_test/queries/test_cache_state.py +++ b/tests-unit/assets_test/queries/test_cache_state.py @@ -1,9 +1,21 @@ """Tests for cache_state query functions.""" from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetCacheState -from app.assets.database.queries import list_cache_states_by_asset_id -from app.assets.helpers import pick_best_live_path +from app.assets.database.models import Asset, AssetCacheState, AssetInfo +from app.assets.database.queries import ( + list_cache_states_by_asset_id, + upsert_cache_state, + delete_cache_states_outside_prefixes, + get_orphaned_seed_asset_ids, + delete_assets_by_ids, + get_cache_states_for_prefixes, + bulk_set_needs_verify, + delete_cache_states_by_ids, + delete_orphaned_seed_asset, + bulk_insert_cache_states_ignore_conflicts, + get_cache_states_by_paths_and_asset_ids, +) +from app.assets.helpers import pick_best_live_path, utcnow def _make_asset(session: Session, hash_val: str | None = None, size: int = 1024) -> Asset: @@ -118,3 +130,284 @@ class TestPickBestLivePathWithMocking: result = pick_best_live_path([MockState()]) assert result == "" + + +class TestUpsertCacheState: + def test_creates_new_state(self, session: Session): + asset = _make_asset(session, "hash1") + created, updated = upsert_cache_state( + session, asset_id=asset.id, file_path="/new/path.bin", mtime_ns=12345 + ) + session.commit() + + assert created is True + assert updated is False + state = session.query(AssetCacheState).filter_by(file_path="/new/path.bin").one() + assert state.asset_id == asset.id + assert state.mtime_ns == 12345 + + def test_returns_existing_without_update(self, session: Session): + asset = _make_asset(session, "hash1") + upsert_cache_state(session, asset_id=asset.id, file_path="/existing.bin", mtime_ns=100) + session.commit() + + created, updated = upsert_cache_state( + session, asset_id=asset.id, file_path="/existing.bin", mtime_ns=100 + ) + session.commit() + + assert created is False + assert updated is False + + def test_updates_existing_with_new_mtime(self, session: Session): + asset = _make_asset(session, "hash1") + upsert_cache_state(session, asset_id=asset.id, file_path="/update.bin", mtime_ns=100) + session.commit() + + created, updated = upsert_cache_state( + session, asset_id=asset.id, file_path="/update.bin", mtime_ns=200 + ) + session.commit() + + assert created is False + assert updated is True + state = session.query(AssetCacheState).filter_by(file_path="/update.bin").one() + assert state.mtime_ns == 200 + + +class TestDeleteCacheStatesOutsidePrefixes: + def test_deletes_states_outside_prefixes(self, session: Session, tmp_path): + asset = _make_asset(session, "hash1") + valid_dir = tmp_path / "valid" + valid_dir.mkdir() + invalid_dir = tmp_path / "invalid" + invalid_dir.mkdir() + + valid_path = str(valid_dir / "file.bin") + invalid_path = str(invalid_dir / "file.bin") + + _make_cache_state(session, asset, valid_path) + _make_cache_state(session, asset, invalid_path) + session.commit() + + deleted = delete_cache_states_outside_prefixes(session, [str(valid_dir)]) + session.commit() + + assert deleted == 1 + remaining = session.query(AssetCacheState).all() + assert len(remaining) == 1 + assert remaining[0].file_path == valid_path + + def test_empty_prefixes_deletes_nothing(self, session: Session): + asset = _make_asset(session, "hash1") + _make_cache_state(session, asset, "/some/path.bin") + session.commit() + + deleted = delete_cache_states_outside_prefixes(session, []) + + assert deleted == 0 + + +class TestGetOrphanedSeedAssetIds: + def test_returns_orphaned_seed_assets(self, session: Session): + # Seed asset (hash=None) with no cache states + orphan = _make_asset(session, hash_val=None) + # Seed asset with cache state (not orphaned) + with_state = _make_asset(session, hash_val=None) + _make_cache_state(session, with_state, "/has/state.bin") + # Regular asset (hash not None) - should not be returned + _make_asset(session, hash_val="blake3:regular") + session.commit() + + orphaned = get_orphaned_seed_asset_ids(session) + + assert orphan.id in orphaned + assert with_state.id not in orphaned + + +class TestDeleteAssetsByIds: + def test_deletes_assets_and_infos(self, session: Session): + asset = _make_asset(session, "hash1") + now = utcnow() + info = AssetInfo( + owner_id="", name="test", asset_id=asset.id, + created_at=now, updated_at=now, last_access_time=now + ) + session.add(info) + session.commit() + + deleted = delete_assets_by_ids(session, [asset.id]) + session.commit() + + assert deleted == 1 + assert session.query(Asset).count() == 0 + assert session.query(AssetInfo).count() == 0 + + def test_empty_list_deletes_nothing(self, session: Session): + _make_asset(session, "hash1") + session.commit() + + deleted = delete_assets_by_ids(session, []) + + assert deleted == 0 + assert session.query(Asset).count() == 1 + + +class TestGetCacheStatesForPrefixes: + def test_returns_states_matching_prefix(self, session: Session, tmp_path): + asset = _make_asset(session, "hash1") + dir1 = tmp_path / "dir1" + dir1.mkdir() + dir2 = tmp_path / "dir2" + dir2.mkdir() + + path1 = str(dir1 / "file.bin") + path2 = str(dir2 / "file.bin") + + _make_cache_state(session, asset, path1, mtime_ns=100) + _make_cache_state(session, asset, path2, mtime_ns=200) + session.commit() + + rows = get_cache_states_for_prefixes(session, [str(dir1)]) + + assert len(rows) == 1 + assert rows[0].file_path == path1 + + def test_empty_prefixes_returns_empty(self, session: Session): + asset = _make_asset(session, "hash1") + _make_cache_state(session, asset, "/some/path.bin") + session.commit() + + rows = get_cache_states_for_prefixes(session, []) + + assert rows == [] + + +class TestBulkSetNeedsVerify: + def test_sets_needs_verify_flag(self, session: Session): + asset = _make_asset(session, "hash1") + state1 = _make_cache_state(session, asset, "/path1.bin", needs_verify=False) + state2 = _make_cache_state(session, asset, "/path2.bin", needs_verify=False) + session.commit() + + updated = bulk_set_needs_verify(session, [state1.id, state2.id], True) + session.commit() + + assert updated == 2 + session.refresh(state1) + session.refresh(state2) + assert state1.needs_verify is True + assert state2.needs_verify is True + + def test_empty_list_updates_nothing(self, session: Session): + updated = bulk_set_needs_verify(session, [], True) + assert updated == 0 + + +class TestDeleteCacheStatesByIds: + def test_deletes_states_by_id(self, session: Session): + asset = _make_asset(session, "hash1") + state1 = _make_cache_state(session, asset, "/path1.bin") + _make_cache_state(session, asset, "/path2.bin") + session.commit() + + deleted = delete_cache_states_by_ids(session, [state1.id]) + session.commit() + + assert deleted == 1 + assert session.query(AssetCacheState).count() == 1 + + def test_empty_list_deletes_nothing(self, session: Session): + deleted = delete_cache_states_by_ids(session, []) + assert deleted == 0 + + +class TestDeleteOrphanedSeedAsset: + def test_deletes_seed_asset_and_infos(self, session: Session): + asset = _make_asset(session, hash_val=None) + now = utcnow() + info = AssetInfo( + owner_id="", name="test", asset_id=asset.id, + created_at=now, updated_at=now, last_access_time=now + ) + session.add(info) + session.commit() + + deleted = delete_orphaned_seed_asset(session, asset.id) + session.commit() + + assert deleted is True + assert session.query(Asset).count() == 0 + assert session.query(AssetInfo).count() == 0 + + def test_returns_false_for_nonexistent(self, session: Session): + deleted = delete_orphaned_seed_asset(session, "nonexistent-id") + assert deleted is False + + +class TestBulkInsertCacheStatesIgnoreConflicts: + def test_inserts_multiple_states(self, session: Session): + asset = _make_asset(session, "hash1") + rows = [ + {"asset_id": asset.id, "file_path": "/bulk1.bin", "mtime_ns": 100}, + {"asset_id": asset.id, "file_path": "/bulk2.bin", "mtime_ns": 200}, + ] + bulk_insert_cache_states_ignore_conflicts(session, rows) + session.commit() + + assert session.query(AssetCacheState).count() == 2 + + def test_ignores_conflicts(self, session: Session): + asset = _make_asset(session, "hash1") + _make_cache_state(session, asset, "/existing.bin", mtime_ns=100) + session.commit() + + rows = [ + {"asset_id": asset.id, "file_path": "/existing.bin", "mtime_ns": 999}, + {"asset_id": asset.id, "file_path": "/new.bin", "mtime_ns": 200}, + ] + bulk_insert_cache_states_ignore_conflicts(session, rows) + session.commit() + + assert session.query(AssetCacheState).count() == 2 + existing = session.query(AssetCacheState).filter_by(file_path="/existing.bin").one() + assert existing.mtime_ns == 100 # Original value preserved + + def test_empty_list_is_noop(self, session: Session): + bulk_insert_cache_states_ignore_conflicts(session, []) + assert session.query(AssetCacheState).count() == 0 + + +class TestGetCacheStatesByPathsAndAssetIds: + def test_returns_matching_paths(self, session: Session): + asset1 = _make_asset(session, "hash1") + asset2 = _make_asset(session, "hash2") + + _make_cache_state(session, asset1, "/path1.bin") + _make_cache_state(session, asset2, "/path2.bin") + session.commit() + + path_to_asset = { + "/path1.bin": asset1.id, + "/path2.bin": asset2.id, + } + winners = get_cache_states_by_paths_and_asset_ids(session, path_to_asset) + + assert winners == {"/path1.bin", "/path2.bin"} + + def test_excludes_non_matching_asset_ids(self, session: Session): + asset1 = _make_asset(session, "hash1") + asset2 = _make_asset(session, "hash2") + + _make_cache_state(session, asset1, "/path1.bin") + session.commit() + + # Path exists but with different asset_id + path_to_asset = {"/path1.bin": asset2.id} + winners = get_cache_states_by_paths_and_asset_ids(session, path_to_asset) + + assert winners == set() + + def test_empty_dict_returns_empty(self, session: Session): + winners = get_cache_states_by_paths_and_asset_ids(session, {}) + assert winners == set() diff --git a/tests-unit/assets_test/queries/test_metadata.py b/tests-unit/assets_test/queries/test_metadata.py index 176a6c216..233b3c012 100644 --- a/tests-unit/assets_test/queries/test_metadata.py +++ b/tests-unit/assets_test/queries/test_metadata.py @@ -3,7 +3,8 @@ from sqlalchemy.orm import Session from app.assets.database.models import Asset, AssetInfo, AssetInfoMeta from app.assets.database.queries import list_asset_infos_page -from app.assets.helpers import utcnow, project_kv +from app.assets.database.queries.asset_info import project_kv +from app.assets.helpers import utcnow def _make_asset(session: Session, hash_val: str) -> Asset: diff --git a/tests-unit/assets_test/queries/test_tags.py b/tests-unit/assets_test/queries/test_tags.py index faf371b40..aaf4d3099 100644 --- a/tests-unit/assets_test/queries/test_tags.py +++ b/tests-unit/assets_test/queries/test_tags.py @@ -1,7 +1,7 @@ import pytest from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetInfo, AssetInfoTag, Tag +from app.assets.database.models import Asset, AssetInfo, AssetInfoTag, AssetInfoMeta, Tag from app.assets.database.queries import ( ensure_tags_exist, get_asset_tags, @@ -11,6 +11,7 @@ from app.assets.database.queries import ( add_missing_tag_for_asset_id, remove_missing_tag_for_asset_id, list_tags_with_usage, + bulk_insert_tags_and_meta, ) from app.assets.helpers import utcnow @@ -295,3 +296,71 @@ class TestListTagsWithUsage: tag_dict = {name: count for name, _, count in rows} assert tag_dict.get("shared-tag", 0) == 1 assert tag_dict.get("owner-tag", 0) == 1 + + +class TestBulkInsertTagsAndMeta: + def test_inserts_tags(self, session: Session): + asset = _make_asset(session, "hash1") + info = _make_asset_info(session, asset) + ensure_tags_exist(session, ["bulk-tag1", "bulk-tag2"]) + session.commit() + + now = utcnow() + tag_rows = [ + {"asset_info_id": info.id, "tag_name": "bulk-tag1", "origin": "manual", "added_at": now}, + {"asset_info_id": info.id, "tag_name": "bulk-tag2", "origin": "manual", "added_at": now}, + ] + bulk_insert_tags_and_meta(session, tag_rows=tag_rows, meta_rows=[]) + session.commit() + + tags = get_asset_tags(session, asset_info_id=info.id) + assert set(tags) == {"bulk-tag1", "bulk-tag2"} + + def test_inserts_meta(self, session: Session): + asset = _make_asset(session, "hash1") + info = _make_asset_info(session, asset) + session.commit() + + meta_rows = [ + { + "asset_info_id": info.id, + "key": "meta-key", + "ordinal": 0, + "val_str": "meta-value", + "val_num": None, + "val_bool": None, + "val_json": None, + }, + ] + bulk_insert_tags_and_meta(session, tag_rows=[], meta_rows=meta_rows) + session.commit() + + meta = session.query(AssetInfoMeta).filter_by(asset_info_id=info.id).all() + assert len(meta) == 1 + assert meta[0].key == "meta-key" + assert meta[0].val_str == "meta-value" + + def test_ignores_conflicts(self, session: Session): + asset = _make_asset(session, "hash1") + info = _make_asset_info(session, asset) + ensure_tags_exist(session, ["existing-tag"]) + add_tags_to_asset_info(session, asset_info_id=info.id, tags=["existing-tag"]) + session.commit() + + now = utcnow() + tag_rows = [ + {"asset_info_id": info.id, "tag_name": "existing-tag", "origin": "duplicate", "added_at": now}, + ] + bulk_insert_tags_and_meta(session, tag_rows=tag_rows, meta_rows=[]) + session.commit() + + # Should still have only one tag link + links = session.query(AssetInfoTag).filter_by(asset_info_id=info.id, tag_name="existing-tag").all() + assert len(links) == 1 + # Origin should be original, not overwritten + assert links[0].origin == "manual" + + def test_empty_lists_is_noop(self, session: Session): + bulk_insert_tags_and_meta(session, tag_rows=[], meta_rows=[]) + assert session.query(AssetInfoTag).count() == 0 + assert session.query(AssetInfoMeta).count() == 0