mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-02-06 19:42:34 +08:00
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:
parent
9f9db2c2c2
commit
4e02245012
@ -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
|
||||
|
||||
@ -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:<hex>' or raises UploadError.
|
||||
"""
|
||||
s = s.strip().lower()
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from typing import Iterable
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import select
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user