diff --git a/app/assets/api/routes.py b/app/assets/api/routes.py index c7fc7022b22..08e7ab892c1 100644 --- a/app/assets/api/routes.py +++ b/app/assets/api/routes.py @@ -501,6 +501,7 @@ async def list_assets_route(request: web.Request) -> web.Response: any_tags=tuple(tags_any), none_tags=tuple(tags_none), name_contains=q.name_contains, + hash=q.hash, limit=fetch_limit, offset=q.offset, sort=sort, diff --git a/app/assets/api/schemas_in.py b/app/assets/api/schemas_in.py index ccf51d7dad7..cbcf9ae0de1 100644 --- a/app/assets/api/schemas_in.py +++ b/app/assets/api/schemas_in.py @@ -65,6 +65,13 @@ class ListAssetsQuery(BaseModel): tags_none: list[str] = Field(default_factory=list) name_contains: str | None = None + # Filter by exact content hash (the `hash` field of each listed asset) + hash: str | None = None + + # Accepted for API compatibility; has no effect, as there is no shared + # asset pool to include or exclude + include_public: bool = True + limit: conint(ge=1, le=500) = 20 offset: conint(ge=0) = 0 # Opaque keyset cursor. When supplied, `offset` is ignored. Cursor pagination @@ -97,6 +104,15 @@ def _split_csv_tags(cls, v): return out return v + @field_validator("hash", mode="before") + @classmethod + def _normalize_hash(cls, v): + # Stored hashes are lowercase `blake3:`; no pattern is enforced + if isinstance(v, str): + return v.strip().lower() + return v + + class UpdateAssetBody(BaseModel): name: str | None = None user_metadata: dict[str, Any] | None = None diff --git a/app/assets/database/queries/records.py b/app/assets/database/queries/records.py index 19dec16cf38..3a4bf31688e 100644 --- a/app/assets/database/queries/records.py +++ b/app/assets/database/queries/records.py @@ -37,6 +37,7 @@ class RecordPageSpec(NamedTuple): any_tags: tuple[str, ...] = () none_tags: tuple[str, ...] = () name_contains: str | None = None + hash: str | None = None limit: int = 20 offset: int = 0 sort: RecordSortField = "created_at" @@ -227,6 +228,9 @@ def list_records_page( filters.append( Asset.name.ilike(f"%{escaped_name}%", escape=escape_character) ) + # Not truthiness: an empty hash matches nothing, an omitted one filters nothing + if spec.hash is not None: + filters.append(AssetContent.hash == spec.hash) sort_columns = { "name": Asset.name, diff --git a/tests-unit/assets_test/test_list_filter.py b/tests-unit/assets_test/test_list_filter.py index d738f6accb6..22d67273db9 100644 --- a/tests-unit/assets_test/test_list_filter.py +++ b/tests-unit/assets_test/test_list_filter.py @@ -332,3 +332,95 @@ async def test_missing_content_is_reachable_via_tags_all_missing( body = _asset_list_body(response) assert {asset["id"] for asset in body["assets"]} == {missing.id} assert body["total"] == 1 + + +def _seed_hashed_record(session, name: str, digest_char: str) -> tuple[str, str]: + asset_hash = "blake3:" + digest_char * 64 + content = create_content( + session, path=f"/output/{name}", hash=asset_hash, size_bytes=1 + ) + record = create_record( + session, content_id=content.id, name=name, tags=("hash-case",) + ) + return record.id, asset_hash + + +@pytest.mark.asyncio +async def test_hash_filter_returns_exact_match(route_database: RouteDatabase) -> None: + _, session = route_database + match, match_hash = _seed_hashed_record(session, "a.png", "a") + _seed_hashed_record(session, "b.png", "b") + session.commit() + + response = await _request_assets(urllib.parse.urlencode({"hash": match_hash})) + + body = _asset_list_body(response) + assert [asset["id"] for asset in body["assets"]] == [match] + assert body["assets"][0]["hash"] == match_hash + assert body["total"] == 1 + + +@pytest.mark.asyncio +async def test_hash_filter_unknown_hash_returns_empty_page( + route_database: RouteDatabase, +) -> None: + _, session = route_database + _seed_hashed_record(session, "a.png", "a") + session.commit() + + response = await _request_assets( + urllib.parse.urlencode({"hash": "blake3:" + "0" * 64}) + ) + + body = _asset_list_body(response) + assert body["assets"] == [] + assert body["total"] == 0 + + +@pytest.mark.asyncio +async def test_hash_filter_normalizes_case_and_whitespace( + route_database: RouteDatabase, +) -> None: + _, session = route_database + match, match_hash = _seed_hashed_record(session, "a.png", "a") + _seed_hashed_record(session, "b.png", "b") + session.commit() + + response = await _request_assets( + urllib.parse.urlencode({"hash": f" {match_hash.upper()} "}) + ) + + body = _asset_list_body(response) + assert [asset["id"] for asset in body["assets"]] == [match] + assert body["total"] == 1 + + +@pytest.mark.asyncio +async def test_empty_hash_matches_nothing(route_database: RouteDatabase) -> None: + _, session = route_database + _seed_hashed_record(session, "a.png", "a") + session.commit() + + response = await _request_assets("hash=") + + body = _asset_list_body(response) + assert body["assets"] == [] + assert body["total"] == 0 + + +@pytest.mark.parametrize("include_public", ("false", "true")) +@pytest.mark.asyncio +async def test_include_public_is_accepted_without_effect( + route_database: RouteDatabase, + include_public: str, +) -> None: + _, session = route_database + record = _seed_record(session, RecordSeed("public.png", ("public-case",))) + session.commit() + + response = await _request_assets( + f"tags_all=public-case&include_public={include_public}" + ) + + body = _asset_list_body(response) + assert [asset["id"] for asset in body["assets"]] == [record.id]