Files
knowledge-adapter-service/tests/test_asset_repository.py
T
姚章浩 693cd952cd feat(asset): 清理被截断的结尾图片引用,修复整单 502
仓库首次入库(此前仅存在于本地工作区与测试服构建产物中,未上传)。

改动(src/knowledge_adapter/asset_content_processor.py):
- 新增 _TRUNCATED_TRAILING_MARKDOWN_RESOURCE_IMAGE 规则,清除 chunk 末尾被截断的
  ![](resource://xxxx 残片(WeKnora 分块在图片引用中间截断时产生,旧逻辑无法匹配完整引用,
  残留 resource:// 触发 ContentProcessingError,导致整单 502)
- 回归测试 tests/test_asset_content_processor.py::
  test_truncated_trailing_markdown_resource_image_is_removed

验证证据:
- 测试服 120.76.217.51,镜像 knowledge-adapter-service:reviewed-test-aeab4b5fca2d
  (release aeab4b5fca2d8661):KB d015d05c-25c8-4d63-b2ab-e8590d3c2dfb 5 个查询全部
  http=200、records=5、残留 resource:// = 0;KB b9dacd62 无退化;容器日志无 error/exception
- 本地:pytest 141 项全通过、ruff 全通过
- 该文件与 258c31e0fe07fd08 源码的差异经逐文件 md5 比对,为唯一变更文件
2026-09-16 10:01:27 +08:00

210 lines
6.8 KiB
Python

from contextlib import AbstractAsyncContextManager
from datetime import UTC, datetime
from types import TracebackType
from uuid import UUID
import pytest
from knowledge_adapter.asset_repository import (
AssetMapping,
AssetMappingConflictError,
AssetRepository,
AssetRepositoryError,
)
SOURCE_REF = "resource://AbCdEfGhIjKlMnOpQrSt_1"
ASSET_ID = UUID("c7f61f89-19ce-45b1-ac60-dbeed5e2a491")
CREATED_AT = datetime(2026, 8, 24, tzinfo=UTC)
class FakeAssetConnection:
def __init__(self, row: dict[str, object] | None) -> None:
self.row = row
self.query = ""
self.args: tuple[object, ...] = ()
async def fetchrow(self, query: str, *args: object) -> dict[str, object] | None:
self.query = query
self.args = args
return self.row
class FailingAssetConnection(FakeAssetConnection):
async def fetchrow(self, query: str, *args: object) -> dict[str, object] | None:
raise RuntimeError("postgresql://adapter:secret@db object_key=private/image.png")
class AcquireAssetConnection(AbstractAsyncContextManager[FakeAssetConnection]):
def __init__(self, connection: FakeAssetConnection) -> None:
self.connection = connection
async def __aenter__(self) -> FakeAssetConnection:
return self.connection
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
return None
class FakeAssetPool:
def __init__(self, connection: FakeAssetConnection) -> None:
self.connection = connection
def acquire(self) -> AcquireAssetConnection:
return AcquireAssetConnection(self.connection)
async def test_get_or_create_uses_atomic_source_ref_upsert() -> None:
connection = FakeAssetConnection(
{
"asset_id": ASSET_ID,
"source_ref": SOURCE_REF,
"object_key": "weknora/10008/assets/image.png",
"content_type": "image/png",
"created_at": CREATED_AT,
"status": "active",
}
)
repository = AssetRepository(pool=FakeAssetPool(connection))
mapping = await repository.get_or_create(
source_ref=SOURCE_REF,
object_key="weknora/10008/assets/image.png",
content_type="image/png",
)
assert mapping == AssetMapping(
asset_id=ASSET_ID,
source_ref=SOURCE_REF,
object_key="weknora/10008/assets/image.png",
content_type="image/png",
created_at=CREATED_AT,
status="active",
)
generated_asset_id = connection.args[0]
assert isinstance(generated_asset_id, UUID)
assert generated_asset_id.version == 4
assert connection.args[1:] == (
SOURCE_REF,
"weknora/10008/assets/image.png",
"image/png",
)
sql = " ".join(connection.query.lower().split())
assert "insert into asset_mapping" in sql
assert "(asset_id, source_ref, object_key, content_type, status)" in sql
assert "values ($1, $2, $3, $4, 'active')" in sql
assert "on conflict (source_ref) do update" in sql
assert "source_ref = asset_mapping.source_ref" in sql
assert "where asset_mapping.object_key = excluded.object_key" in sql
assert (
"returning asset_id, source_ref, object_key, content_type, created_at, status" in sql
)
async def test_get_or_create_rejects_source_ref_object_key_conflict() -> None:
repository = AssetRepository(pool=FakeAssetPool(FakeAssetConnection(None)))
with pytest.raises(AssetMappingConflictError, match=r"^asset mapping conflict$"):
await repository.get_or_create(
source_ref=SOURCE_REF,
object_key="different/object.png",
content_type="image/png",
)
async def test_get_or_create_maps_database_error_to_safe_error() -> None:
repository = AssetRepository(
pool=FakeAssetPool(FailingAssetConnection(None)),
)
with pytest.raises(AssetRepositoryError) as captured:
await repository.get_or_create(
source_ref=SOURCE_REF,
object_key="weknora/10008/assets/image.png",
content_type="image/png",
)
assert str(captured.value) == "asset repository unavailable"
assert "postgresql" not in str(captured.value)
assert "object_key" not in str(captured.value)
async def test_get_active_by_source_ref_reads_only_active_mapping() -> None:
connection = FakeAssetConnection(
{
"asset_id": ASSET_ID,
"source_ref": SOURCE_REF,
"object_key": "weknora/10008/assets/image.png",
"content_type": "image/png",
"created_at": CREATED_AT,
"status": "active",
}
)
repository = AssetRepository(pool=FakeAssetPool(connection))
mapping = await repository.get_active_by_source_ref(SOURCE_REF)
assert mapping is not None
assert mapping.asset_id == ASSET_ID
assert connection.args == (SOURCE_REF,)
sql = " ".join(connection.query.lower().split())
assert sql.startswith(
"select asset_id, source_ref, object_key, content_type, created_at, status "
"from asset_mapping"
)
assert "source_ref = $1" in sql
assert "status = 'active'" in sql
async def test_get_active_by_source_ref_returns_none_when_missing() -> None:
repository = AssetRepository(pool=FakeAssetPool(FakeAssetConnection(None)))
mapping = await repository.get_active_by_source_ref(SOURCE_REF)
assert mapping is None
async def test_get_active_by_source_ref_maps_database_error_to_safe_error() -> None:
repository = AssetRepository(
pool=FakeAssetPool(FailingAssetConnection(None)),
)
with pytest.raises(AssetRepositoryError, match=r"^asset repository unavailable$"):
await repository.get_active_by_source_ref(SOURCE_REF)
async def test_get_active_by_asset_id_reads_only_active_mapping() -> None:
connection = FakeAssetConnection(
{
"asset_id": ASSET_ID,
"source_ref": SOURCE_REF,
"object_key": "weknora/10008/assets/image.png",
"content_type": "image/png",
"created_at": CREATED_AT,
"status": "active",
}
)
repository = AssetRepository(pool=FakeAssetPool(connection))
mapping = await repository.get_active_by_asset_id(ASSET_ID)
assert mapping is not None
assert mapping.object_key == "weknora/10008/assets/image.png"
assert connection.args == (ASSET_ID,)
sql = " ".join(connection.query.lower().split())
assert "from asset_mapping" in sql
assert "asset_id = $1" in sql
assert "status = 'active'" in sql
async def test_get_active_by_asset_id_returns_none_when_missing() -> None:
repository = AssetRepository(pool=FakeAssetPool(FakeAssetConnection(None)))
mapping = await repository.get_active_by_asset_id(ASSET_ID)
assert mapping is None