fix: ruff linting errors and add comprehensive test coverage for asset queries

- Fix unused imports in routes.py, asset.py, manager.py, asset_management.py, ingest.py
- Fix whitespace issues in upload.py, asset_info.py, ingest.py
- Fix typo in manager.py (stray character after result["asset"])
- Fix broken import in test_metadata.py (project_kv moved to asset_info.py)
- Add fixture override in queries/conftest.py for unit test isolation

Add 48 new tests covering all previously untested query functions:
- asset.py: upsert_asset, bulk_insert_assets
- cache_state.py: 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
- asset_info.py: insert_asset_info, get_or_create_asset_info,
  update_asset_info_timestamps, replace_asset_info_metadata_projection,
  bulk_insert_asset_infos_ignore_conflicts, get_asset_info_ids_by_ids
- tags.py: bulk_insert_tags_and_meta

Total: 119 tests pass (up from 71)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Luke Mino-Altherr 2026-02-03 13:21:12 -08:00
parent 9f9db2c2c2
commit 4e02245012
13 changed files with 749 additions and 20 deletions

View File

@ -2,7 +2,6 @@ import logging
import uuid import uuid
import urllib.parse import urllib.parse
import os import os
import contextlib
from aiohttp import web from aiohttp import web
from pydantic import ValidationError 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 app.assets.services.scanner import seed_assets
from typing import Any from typing import Any
import folder_paths
ROUTES = web.RouteTableDef() ROUTES = web.RouteTableDef()
USER_MANAGER: user_manager.UserManager | None = None USER_MANAGER: user_manager.UserManager | None = None

View File

@ -16,7 +16,7 @@ from app.assets.api.schemas_in import ParsedUpload, UploadError
def validate_hash_format(s: str) -> str: def validate_hash_format(s: str) -> str:
""" """
Validate and normalize a hash string. Validate and normalize a hash string.
Returns canonical 'blake3:<hex>' or raises UploadError. Returns canonical 'blake3:<hex>' or raises UploadError.
""" """
s = s.strip().lower() s = s.strip().lower()

View File

@ -1,4 +1,3 @@
from typing import Iterable
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import select from sqlalchemy import select

View File

@ -1,7 +1,7 @@
""" """
Pure atomic database queries for AssetInfo operations. 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. no filesystem operations, no orchestration across multiple tables.
""" """
from collections import defaultdict from collections import defaultdict
@ -246,7 +246,7 @@ def get_or_create_asset_info(
) )
if info: if info:
return info, True return info, True
existing = session.execute( existing = session.execute(
select(AssetInfo) select(AssetInfo)
.where( .where(

View File

@ -28,9 +28,7 @@ from app.assets.api.upload import _cleanup_temp
from app.assets.database.queries import ( from app.assets.database.queries import (
asset_exists_by_hash, asset_exists_by_hash,
fetch_asset_info_and_asset, fetch_asset_info_and_asset,
fetch_asset_info_asset_and_tags,
get_asset_by_hash, get_asset_by_hash,
get_asset_info_by_id,
get_asset_tags, get_asset_tags,
list_asset_infos_page, list_asset_infos_page,
list_cache_states_by_asset_id, list_cache_states_by_asset_id,
@ -417,7 +415,7 @@ def set_asset_preview(
owner_id=owner_id, owner_id=owner_id,
) )
info = result["info"] info = result["info"]
asset = result["asset"]T asset = result["asset"]
tags = result["tags"] tags = result["tags"]
return schemas_out.AssetDetail( return schemas_out.AssetDetail(

View File

@ -18,7 +18,6 @@ from app.assets.services.path_utils import compute_relative_filename
from app.assets.database.queries import ( from app.assets.database.queries import (
asset_info_exists_for_asset_id, asset_info_exists_for_asset_id,
delete_asset_info_by_id, delete_asset_info_by_id,
fetch_asset_info_and_asset,
fetch_asset_info_asset_and_tags, fetch_asset_info_asset_and_tags,
get_asset_info_by_id, get_asset_info_by_id,
list_cache_states_by_asset_id, list_cache_states_by_asset_id,

View File

@ -13,7 +13,7 @@ from sqlalchemy import select
from app.assets.database.models import Asset, Tag from app.assets.database.models import Asset, Tag
from app.database.db import create_session 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.services.path_utils import compute_relative_filename
from app.assets.database.queries import ( from app.assets.database.queries import (
get_asset_by_hash, get_asset_by_hash,
@ -26,7 +26,6 @@ from app.assets.database.queries import (
upsert_asset, upsert_asset,
upsert_cache_state, upsert_cache_state,
add_tags_to_asset_info, add_tags_to_asset_info,
ensure_tags_exist,
get_asset_tags, get_asset_tags,
) )
@ -147,7 +146,7 @@ def register_existing_asset(
) -> dict: ) -> dict:
""" """
Create or return existing AssetInfo for an asset that already exists by hash. 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. Returns dict with asset and info details, or raises ValueError if hash not found.
""" """
with create_session() as session: with create_session() as session:

View File

@ -12,3 +12,9 @@ def session():
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
with Session(engine) as sess: with Session(engine) as sess:
yield sess yield sess
@pytest.fixture(autouse=True)
def autoclean_unit_test_assets():
"""Override parent autouse fixture - query tests don't need server cleanup."""
yield

View File

@ -1,7 +1,13 @@
import uuid
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.assets.database.models import Asset 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: class TestAssetExistsByHash:
@ -37,3 +43,121 @@ class TestGetAssetByHash:
assert result.id == asset.id assert result.id == asset.id
assert result.size_bytes == 200 assert result.size_bytes == 200
assert result.mime_type == "image/png" 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

View File

@ -1,16 +1,24 @@
import time
import uuid
import pytest import pytest
from sqlalchemy.orm import Session 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 ( from app.assets.database.queries import (
asset_info_exists_for_asset_id, asset_info_exists_for_asset_id,
get_asset_info_by_id, get_asset_info_by_id,
insert_asset_info,
get_or_create_asset_info,
update_asset_info_timestamps,
list_asset_infos_page, list_asset_infos_page,
fetch_asset_info_asset_and_tags, fetch_asset_info_asset_and_tags,
fetch_asset_info_and_asset, fetch_asset_info_and_asset,
touch_asset_info_by_id, touch_asset_info_by_id,
replace_asset_info_metadata_projection,
delete_asset_info_by_id, delete_asset_info_by_id,
set_asset_info_preview, set_asset_info_preview,
bulk_insert_asset_infos_ignore_conflicts,
get_asset_info_ids_by_ids,
ensure_tags_exist, ensure_tags_exist,
add_tags_to_asset_info, add_tags_to_asset_info,
) )
@ -266,3 +274,238 @@ class TestSetAssetInfoPreview:
with pytest.raises(ValueError, match="Preview Asset"): with pytest.raises(ValueError, match="Preview Asset"):
set_asset_info_preview(session, asset_info_id=info.id, preview_asset_id="nonexistent") 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()

View File

@ -1,9 +1,21 @@
"""Tests for cache_state query functions.""" """Tests for cache_state query functions."""
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.assets.database.models import Asset, AssetCacheState from app.assets.database.models import Asset, AssetCacheState, AssetInfo
from app.assets.database.queries import list_cache_states_by_asset_id from app.assets.database.queries import (
from app.assets.helpers import pick_best_live_path 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: 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()]) result = pick_best_live_path([MockState()])
assert result == "" 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()

View File

@ -3,7 +3,8 @@ from sqlalchemy.orm import Session
from app.assets.database.models import Asset, AssetInfo, AssetInfoMeta from app.assets.database.models import Asset, AssetInfo, AssetInfoMeta
from app.assets.database.queries import list_asset_infos_page 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: def _make_asset(session: Session, hash_val: str) -> Asset:

View File

@ -1,7 +1,7 @@
import pytest import pytest
from sqlalchemy.orm import Session 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 ( from app.assets.database.queries import (
ensure_tags_exist, ensure_tags_exist,
get_asset_tags, get_asset_tags,
@ -11,6 +11,7 @@ from app.assets.database.queries import (
add_missing_tag_for_asset_id, add_missing_tag_for_asset_id,
remove_missing_tag_for_asset_id, remove_missing_tag_for_asset_id,
list_tags_with_usage, list_tags_with_usage,
bulk_insert_tags_and_meta,
) )
from app.assets.helpers import utcnow from app.assets.helpers import utcnow
@ -295,3 +296,71 @@ class TestListTagsWithUsage:
tag_dict = {name: count for name, _, count in rows} tag_dict = {name: count for name, _, count in rows}
assert tag_dict.get("shared-tag", 0) == 1 assert tag_dict.get("shared-tag", 0) == 1
assert tag_dict.get("owner-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