feat: redesign metadata as KV store, add custom properties and WOPI Discovery
Some checks failed
Test / test (push) Failing after 2m32s

Replace one-to-one FileMetadata table with flexible ObjectMetadata KV pairs,
add custom property definitions, WOPI Discovery auto-configuration, and
per-extension action URL support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:21:22 +08:00
parent 743a2c9d65
commit bcb0a9b322
17 changed files with 1943 additions and 471 deletions

View File

@@ -92,9 +92,9 @@ class ObjectFactory:
owner_id=owner_id,
policy_id=policy_id,
size=size,
mime_type=kwargs.get("mime_type"),
source_name=kwargs.get("source_name", name),
upload_session_id=kwargs.get("upload_session_id"),
file_metadata=kwargs.get("file_metadata"),
password=kwargs.get("password"),
)

View File

@@ -0,0 +1,219 @@
"""
自定义属性定义端点集成测试
"""
import pytest
from httpx import AsyncClient
from uuid import UUID, uuid4
# ==================== 获取属性定义列表测试 ====================
@pytest.mark.asyncio
async def test_list_custom_properties_requires_auth(async_client: AsyncClient):
"""测试获取属性定义需要认证"""
response = await async_client.get("/api/v1/object/custom_property")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_custom_properties_empty(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试获取空的属性定义列表"""
response = await async_client.get(
"/api/v1/object/custom_property",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data == []
# ==================== 创建属性定义测试 ====================
@pytest.mark.asyncio
async def test_create_custom_property(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试创建自定义属性"""
response = await async_client.post(
"/api/v1/object/custom_property",
headers=auth_headers,
json={
"name": "评分",
"type": "rating",
"icon": "mdi:star",
},
)
assert response.status_code == 204
# 验证已创建
list_response = await async_client.get(
"/api/v1/object/custom_property",
headers=auth_headers,
)
data = list_response.json()
assert len(data) == 1
assert data[0]["name"] == "评分"
assert data[0]["type"] == "rating"
assert data[0]["icon"] == "mdi:star"
@pytest.mark.asyncio
async def test_create_custom_property_with_options(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试创建带选项的自定义属性"""
response = await async_client.post(
"/api/v1/object/custom_property",
headers=auth_headers,
json={
"name": "分类",
"type": "select",
"options": ["工作", "个人", "归档"],
"default_value": "个人",
},
)
assert response.status_code == 204
list_response = await async_client.get(
"/api/v1/object/custom_property",
headers=auth_headers,
)
data = list_response.json()
prop = next(p for p in data if p["name"] == "分类")
assert prop["type"] == "select"
assert prop["options"] == ["工作", "个人", "归档"]
assert prop["default_value"] == "个人"
@pytest.mark.asyncio
async def test_create_custom_property_duplicate_name(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试创建同名属性返回 409"""
# 先创建
await async_client.post(
"/api/v1/object/custom_property",
headers=auth_headers,
json={"name": "标签", "type": "text"},
)
# 再创建同名
response = await async_client.post(
"/api/v1/object/custom_property",
headers=auth_headers,
json={"name": "标签", "type": "text"},
)
assert response.status_code == 409
# ==================== 更新属性定义测试 ====================
@pytest.mark.asyncio
async def test_update_custom_property(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试更新自定义属性"""
# 先创建
await async_client.post(
"/api/v1/object/custom_property",
headers=auth_headers,
json={"name": "备注", "type": "text"},
)
# 获取 ID
list_response = await async_client.get(
"/api/v1/object/custom_property",
headers=auth_headers,
)
prop_id = next(p["id"] for p in list_response.json() if p["name"] == "备注")
# 更新
response = await async_client.patch(
f"/api/v1/object/custom_property/{prop_id}",
headers=auth_headers,
json={"name": "详细备注", "icon": "mdi:note"},
)
assert response.status_code == 204
# 验证已更新
list_response = await async_client.get(
"/api/v1/object/custom_property",
headers=auth_headers,
)
prop = next(p for p in list_response.json() if p["id"] == prop_id)
assert prop["name"] == "详细备注"
assert prop["icon"] == "mdi:note"
@pytest.mark.asyncio
async def test_update_custom_property_not_found(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试更新不存在的属性返回 404"""
fake_id = str(uuid4())
response = await async_client.patch(
f"/api/v1/object/custom_property/{fake_id}",
headers=auth_headers,
json={"name": "不存在"},
)
assert response.status_code == 404
# ==================== 删除属性定义测试 ====================
@pytest.mark.asyncio
async def test_delete_custom_property(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试删除自定义属性"""
# 先创建
await async_client.post(
"/api/v1/object/custom_property",
headers=auth_headers,
json={"name": "待删除", "type": "text"},
)
# 获取 ID
list_response = await async_client.get(
"/api/v1/object/custom_property",
headers=auth_headers,
)
prop_id = next(p["id"] for p in list_response.json() if p["name"] == "待删除")
# 删除
response = await async_client.delete(
f"/api/v1/object/custom_property/{prop_id}",
headers=auth_headers,
)
assert response.status_code == 204
# 验证已删除
list_response = await async_client.get(
"/api/v1/object/custom_property",
headers=auth_headers,
)
prop_names = [p["name"] for p in list_response.json()]
assert "待删除" not in prop_names
@pytest.mark.asyncio
async def test_delete_custom_property_not_found(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试删除不存在的属性返回 404"""
fake_id = str(uuid4())
response = await async_client.delete(
f"/api/v1/object/custom_property/{fake_id}",
headers=auth_headers,
)
assert response.status_code == 404

View File

@@ -0,0 +1,239 @@
"""
对象元数据端点集成测试
"""
import pytest
from httpx import AsyncClient
from uuid import UUID, uuid4
from sqlmodels import ObjectMetadata
# ==================== 获取元数据测试 ====================
@pytest.mark.asyncio
async def test_get_metadata_requires_auth(async_client: AsyncClient):
"""测试获取元数据需要认证"""
fake_id = str(uuid4())
response = await async_client.get(f"/api/v1/object/{fake_id}/metadata")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_metadata_empty(
async_client: AsyncClient,
auth_headers: dict[str, str],
test_directory_structure: dict[str, UUID],
):
"""测试获取无元数据的对象"""
file_id = test_directory_structure["file_id"]
response = await async_client.get(
f"/api/v1/object/{file_id}/metadata",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["metadatas"] == {}
@pytest.mark.asyncio
async def test_get_metadata_with_entries(
async_client: AsyncClient,
auth_headers: dict[str, str],
test_directory_structure: dict[str, UUID],
initialized_db,
):
"""测试获取有元数据的对象"""
file_id = test_directory_structure["file_id"]
# 直接写入元数据
entries = [
ObjectMetadata(object_id=file_id, name="exif:width", value="1920", is_public=True),
ObjectMetadata(object_id=file_id, name="exif:height", value="1080", is_public=True),
ObjectMetadata(object_id=file_id, name="sys:extract_status", value="done", is_public=False),
]
for entry in entries:
initialized_db.add(entry)
await initialized_db.commit()
response = await async_client.get(
f"/api/v1/object/{file_id}/metadata",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
# sys: 命名空间应被过滤
assert "exif:width" in data["metadatas"]
assert "exif:height" in data["metadatas"]
assert "sys:extract_status" not in data["metadatas"]
assert data["metadatas"]["exif:width"] == "1920"
@pytest.mark.asyncio
async def test_get_metadata_ns_filter(
async_client: AsyncClient,
auth_headers: dict[str, str],
test_directory_structure: dict[str, UUID],
initialized_db,
):
"""测试按命名空间过滤元数据"""
file_id = test_directory_structure["file_id"]
entries = [
ObjectMetadata(object_id=file_id, name="exif:width", value="1920", is_public=True),
ObjectMetadata(object_id=file_id, name="music:title", value="Test Song", is_public=True),
]
for entry in entries:
initialized_db.add(entry)
await initialized_db.commit()
# 只获取 exif 命名空间
response = await async_client.get(
f"/api/v1/object/{file_id}/metadata?ns=exif",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "exif:width" in data["metadatas"]
assert "music:title" not in data["metadatas"]
@pytest.mark.asyncio
async def test_get_metadata_nonexistent_object(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试获取不存在对象的元数据"""
fake_id = str(uuid4())
response = await async_client.get(
f"/api/v1/object/{fake_id}/metadata",
headers=auth_headers,
)
assert response.status_code == 404
# ==================== 更新元数据测试 ====================
@pytest.mark.asyncio
async def test_patch_metadata_requires_auth(async_client: AsyncClient):
"""测试更新元数据需要认证"""
fake_id = str(uuid4())
response = await async_client.patch(
f"/api/v1/object/{fake_id}/metadata",
json={"patches": [{"key": "custom:tag", "value": "test"}]},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_patch_metadata_set_custom(
async_client: AsyncClient,
auth_headers: dict[str, str],
test_directory_structure: dict[str, UUID],
):
"""测试设置自定义元数据"""
file_id = test_directory_structure["file_id"]
response = await async_client.patch(
f"/api/v1/object/{file_id}/metadata",
headers=auth_headers,
json={
"patches": [
{"key": "custom:tag1", "value": "旅游"},
{"key": "custom:tag2", "value": "风景"},
]
},
)
assert response.status_code == 204
# 验证已写入
get_response = await async_client.get(
f"/api/v1/object/{file_id}/metadata?ns=custom",
headers=auth_headers,
)
assert get_response.status_code == 200
data = get_response.json()
assert data["metadatas"]["custom:tag1"] == "旅游"
assert data["metadatas"]["custom:tag2"] == "风景"
@pytest.mark.asyncio
async def test_patch_metadata_update_existing(
async_client: AsyncClient,
auth_headers: dict[str, str],
test_directory_structure: dict[str, UUID],
):
"""测试更新已有的元数据"""
file_id = test_directory_structure["file_id"]
# 先创建
await async_client.patch(
f"/api/v1/object/{file_id}/metadata",
headers=auth_headers,
json={"patches": [{"key": "custom:note", "value": "旧值"}]},
)
# 再更新
response = await async_client.patch(
f"/api/v1/object/{file_id}/metadata",
headers=auth_headers,
json={"patches": [{"key": "custom:note", "value": "新值"}]},
)
assert response.status_code == 204
# 验证已更新
get_response = await async_client.get(
f"/api/v1/object/{file_id}/metadata?ns=custom",
headers=auth_headers,
)
data = get_response.json()
assert data["metadatas"]["custom:note"] == "新值"
@pytest.mark.asyncio
async def test_patch_metadata_delete(
async_client: AsyncClient,
auth_headers: dict[str, str],
test_directory_structure: dict[str, UUID],
):
"""测试删除元数据条目"""
file_id = test_directory_structure["file_id"]
# 先创建
await async_client.patch(
f"/api/v1/object/{file_id}/metadata",
headers=auth_headers,
json={"patches": [{"key": "custom:to_delete", "value": "temp"}]},
)
# 删除value 为 null
response = await async_client.patch(
f"/api/v1/object/{file_id}/metadata",
headers=auth_headers,
json={"patches": [{"key": "custom:to_delete", "value": None}]},
)
assert response.status_code == 204
# 验证已删除
get_response = await async_client.get(
f"/api/v1/object/{file_id}/metadata?ns=custom",
headers=auth_headers,
)
data = get_response.json()
assert "custom:to_delete" not in data["metadatas"]
@pytest.mark.asyncio
async def test_patch_metadata_reject_non_custom_namespace(
async_client: AsyncClient,
auth_headers: dict[str, str],
test_directory_structure: dict[str, UUID],
):
"""测试拒绝修改非 custom: 命名空间"""
file_id = test_directory_structure["file_id"]
response = await async_client.patch(
f"/api/v1/object/{file_id}/metadata",
headers=auth_headers,
json={"patches": [{"key": "exif:width", "value": "1920"}]},
)
assert response.status_code == 400