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 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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user