mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-18 21:52:59 +08:00
1206 lines
57 KiB
Python
1206 lines
57 KiB
Python
"""FastAPI 公共契约、文件边界和上传行为测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import zipfile
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from PIL import Image
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import select, text
|
|
|
|
from api import ConcurrencyUpdateRequest, app
|
|
from backend.collection_packages import CollectionManifest, CollectionManifestCollection, CollectionManifestMember, serialize_manifest, sha256_bytes
|
|
from backend.database import EMBEDDING_DIMENSIONS, ImageProcessingJob, Meme, MemeTextEmbedding, StorageOperation, Task, create_engine_for_url
|
|
from backend.image_processing import ImageProcessingWorker
|
|
|
|
|
|
def _clear_test_scope() -> None:
|
|
"""清理 API 测试使用的 local scope 业务行,保留安装标记和 scope 命名空间。"""
|
|
url = os.getenv("MEMEMEOW_TEST_DATABASE_URL")
|
|
if not url:
|
|
pytest.skip("未设置 MEMEMEOW_TEST_DATABASE_URL,拒绝清理开发数据库")
|
|
engine = create_engine_for_url(url, pool_size=1, max_overflow=0)
|
|
statements = (
|
|
"DELETE FROM agent_callback_requests",
|
|
"DELETE FROM reverse_image_usage_events",
|
|
"DELETE FROM operation_grants",
|
|
"DELETE FROM image_processing_attempts",
|
|
"DELETE FROM image_processing_stages",
|
|
"DELETE FROM image_processing_jobs",
|
|
"DELETE FROM search_migration_states",
|
|
"DELETE FROM task_lane_resource_slots",
|
|
"DELETE FROM task_lane_slots",
|
|
"DELETE FROM task_batch_items",
|
|
"DELETE FROM task_batches",
|
|
"DELETE FROM tasks",
|
|
"DELETE FROM meme_visual_embeddings",
|
|
"DELETE FROM meme_text_embeddings",
|
|
"DELETE FROM meme_embeddings",
|
|
"DELETE FROM search_heads",
|
|
"DELETE FROM search_generations",
|
|
"DELETE FROM storage_operations",
|
|
"DELETE FROM memes",
|
|
)
|
|
with engine.begin() as connection:
|
|
for statement in statements:
|
|
connection.execute(text(statement))
|
|
engine.dispose()
|
|
|
|
|
|
def test_concurrency_update_request_accepts_large_positive_value() -> None:
|
|
"""设置接口请求模型保留大正整数,Agent 队列不设置数量门禁。"""
|
|
assert ConcurrencyUpdateRequest.model_validate({"value": 128}).opencode_concurrency == 128
|
|
with pytest.raises(ValidationError):
|
|
ConcurrencyUpdateRequest.model_validate({"value": 0})
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
"""使用隔离图片目录启动完整应用生命周期。"""
|
|
test_url = os.getenv("MEMEMEOW_TEST_DATABASE_URL")
|
|
if not test_url:
|
|
pytest.skip("未设置 MEMEMEOW_TEST_DATABASE_URL,跳过 API PostgreSQL 测试")
|
|
monkeypatch.setenv("MEMEMEOW_DATABASE_URL", test_url)
|
|
monkeypatch.setenv("MEMEMEOW_DATA_ROOT", str(tmp_path / "data"))
|
|
monkeypatch.setenv("MEMEMEOW_IMAGE_ROOT", str(tmp_path / "images"))
|
|
monkeypatch.setenv("EMBEDDING_API_KEY", "")
|
|
monkeypatch.setenv("EMBEDDING_BASE_URL", "")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_MODEL", "")
|
|
monkeypatch.setenv("MEMEMEOW_AGENT_CALLBACK_SECRET", "test-agent-callback-secret-1234")
|
|
_clear_test_scope()
|
|
with TestClient(app) as test_client:
|
|
uploaded = test_client.post("/images/upload", files=[("files", ("deleted.png", png_bytes(), "image/png"))]).json()["results"][0]
|
|
|
|
def fake_embed(content, *, filename="image"):
|
|
"""模拟视觉服务,确保夹具直接进入 Agent 阶段而不触发真实模型。"""
|
|
del content, filename
|
|
settings = test_client.app.state.settings
|
|
return {
|
|
"model": settings.visual_model,
|
|
"dimensions": settings.visual_model_dimensions,
|
|
"preprocess_version": settings.visual_preprocess_version,
|
|
"embedding": [1.0] + [0.0] * (settings.visual_model_dimensions - 1),
|
|
}
|
|
|
|
monkeypatch.setattr(test_client.app.state.visual_inference, "embed", fake_embed)
|
|
|
|
def fake_run(path, progress, *, task_id=None, **kwargs):
|
|
"""模拟 Agent 完成前目标图片被删除。"""
|
|
del progress, kwargs
|
|
assert task_id
|
|
path.unlink()
|
|
return {}, "deleted-session"
|
|
|
|
monkeypatch.setattr(test_client.app.state.opencode, "run", fake_run)
|
|
response = test_client.post("/images/context", json={"meme_id": uploaded["meme_id"]})
|
|
assert response.status_code == 202
|
|
task_id = response.json()["task_id"]
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline:
|
|
status = test_client.get(f"/tasks/{task_id}").json()
|
|
if status["status"] in {"succeeded", "failed"}:
|
|
break
|
|
time.sleep(0.01)
|
|
assert status["status"] == "failed"
|
|
assert status["error"]["error"] == "target_changed"
|
|
_clear_test_scope()
|
|
with TestClient(app) as test_client:
|
|
yield test_client, tmp_path
|
|
_clear_test_scope()
|
|
|
|
|
|
def png_bytes(color: str = "red") -> bytes:
|
|
"""生成可解码的小型 PNG 测试数据。"""
|
|
output = io.BytesIO()
|
|
Image.new("RGB", (4, 4), color=color).save(output, format="PNG")
|
|
return output.getvalue()
|
|
|
|
|
|
def collection_zip_bytes() -> bytes:
|
|
"""生成包含一张图片的最小合集包,验证导入任务衔接。"""
|
|
content = png_bytes("green")
|
|
source_id = uuid4().hex
|
|
member = CollectionManifestMember(
|
|
source_meme_id=source_id,
|
|
filename_at_export="imported.png",
|
|
path=f"images/{source_id}.png",
|
|
extension=".png",
|
|
size_bytes=len(content),
|
|
sha256=sha256_bytes(content),
|
|
)
|
|
manifest = CollectionManifest(
|
|
format="mememeow-collection",
|
|
format_version=1,
|
|
collection=CollectionManifestCollection(name=f"导入测试-{source_id[:8]}"),
|
|
members=[member],
|
|
)
|
|
output = io.BytesIO()
|
|
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as archive:
|
|
archive.writestr("manifest.json", serialize_manifest(manifest))
|
|
archive.writestr(member.path, content)
|
|
return output.getvalue()
|
|
|
|
|
|
def test_config_is_masked_and_search_requires_cache(client):
|
|
"""配置响应不泄露密钥,缓存未就绪时搜索返回规范错误。"""
|
|
test_client, _ = client
|
|
config = test_client.get("/config")
|
|
assert config.status_code == 200
|
|
assert "embedding_api_key" not in config.json()
|
|
assert "opencode_executable" not in config.json()
|
|
assert config.json()["embedding_api_key_configured"] is False
|
|
assert config.json()["embedding_cache_ready"] is False
|
|
response = test_client.post("/search", json={"query": "开心"})
|
|
assert response.status_code == 503
|
|
assert response.json()["error"] == "cache_not_ready"
|
|
|
|
|
|
def test_startup_allows_root_orphan_files_without_listing_them(tmp_path, monkeypatch):
|
|
"""启动预检允许根目录孤立图片,并继续只从数据库列出可信 Meme。"""
|
|
test_url = os.getenv("MEMEMEOW_TEST_DATABASE_URL")
|
|
if not test_url:
|
|
pytest.skip("未设置 MEMEMEOW_TEST_DATABASE_URL,跳过 API PostgreSQL 测试")
|
|
image_root = tmp_path / "images"
|
|
image_root.mkdir(parents=True)
|
|
orphan = image_root / "legacy.png"
|
|
orphan.write_bytes(png_bytes())
|
|
monkeypatch.setenv("MEMEMEOW_DATABASE_URL", test_url)
|
|
monkeypatch.setenv("MEMEMEOW_DATA_ROOT", str(tmp_path / "data"))
|
|
monkeypatch.setenv("MEMEMEOW_IMAGE_ROOT", str(image_root))
|
|
monkeypatch.setenv("EMBEDDING_API_KEY", "")
|
|
monkeypatch.setenv("EMBEDDING_BASE_URL", "")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_MODEL", "")
|
|
_clear_test_scope()
|
|
with TestClient(app) as test_client:
|
|
health = test_client.get("/health")
|
|
assert health.status_code == 200
|
|
assert health.json()["storage_preflight"] == {"status": "warning", "orphan_files": 1, "blocking_errors": {}}
|
|
assert test_client.get("/config").json()["storage_preflight"] == health.json()["storage_preflight"]
|
|
assert test_client.get("/images").json()["total"] == 0
|
|
assert orphan.is_file()
|
|
_clear_test_scope()
|
|
|
|
|
|
@pytest.mark.parametrize("payload", [{"query": ""}, {"query": "x", "n_results": 0}, {"query": "x", "n_results": 31}, {"query": "x", "n_results": "5"}])
|
|
def test_search_validation_is_consistent(client, payload):
|
|
"""空查询和越界数量统一返回 400。"""
|
|
test_client, _ = client
|
|
response = test_client.post("/search", json=payload)
|
|
assert response.status_code == 400
|
|
assert response.json()["error"] == "invalid_request"
|
|
|
|
|
|
def test_get_search_is_not_an_alias(client):
|
|
"""旧 GET 检索入口不会执行检索逻辑。"""
|
|
test_client, _ = client
|
|
assert test_client.get("/search", params={"q": "x"}).status_code == 405
|
|
|
|
|
|
def test_upload_lists_and_serves_image(client):
|
|
"""上传后的图片可立即列出并通过受控媒体接口读取。"""
|
|
test_client, _ = client
|
|
response = test_client.post("/images/upload", files=[("files", ("hello.png", png_bytes(), "image/png"))])
|
|
assert response.status_code == 200
|
|
uploaded = response.json()["results"][0]
|
|
assert uploaded["ok"] is True
|
|
meme_id = uploaded["meme_id"]
|
|
UUID(meme_id)
|
|
listing = test_client.get("/images").json()
|
|
assert listing["total"] == 1
|
|
media_url = listing["items"][0]["media_url"]
|
|
media = test_client.get(media_url)
|
|
assert media.status_code == 200
|
|
assert media.headers["content-type"] == "image/png"
|
|
listed = test_client.get("/images").json()["items"][0]
|
|
assert listed["meme_id"] == meme_id
|
|
assert listed["media_url"] == f"/media/{meme_id}"
|
|
assert listed["metadata"]["status"] == "pending"
|
|
assert listed["embedding_status"] == "pending"
|
|
|
|
|
|
def test_upload_rejects_more_than_twenty_files_before_durable_write(client):
|
|
"""超过每请求文件数边界时在任何 Meme 或 operation 写入前拒绝。"""
|
|
test_client, _ = client
|
|
response = test_client.post(
|
|
"/images/upload",
|
|
files=[("files", (f"too-many-{index}.png", png_bytes(), "image/png")) for index in range(21)],
|
|
)
|
|
assert response.status_code == 413
|
|
assert response.json()["error"] == "too_many_files"
|
|
assert test_client.get("/images").json()["total"] == 0
|
|
|
|
|
|
def test_upload_accepts_twenty_files_and_exposes_default_limits(client):
|
|
"""恰好 20 个文件可以逐项入库,配置响应保持 20/客户端 2 并发提示/disabled。"""
|
|
test_client, _ = client
|
|
response = test_client.post(
|
|
"/images/upload",
|
|
files=[("files", (f"limit-{index}.png", png_bytes(), "image/png")) for index in range(20)],
|
|
)
|
|
assert response.status_code == 200
|
|
assert len(response.json()["results"]) == 20
|
|
assert all(item["ok"] for item in response.json()["results"])
|
|
config = test_client.get("/config").json()
|
|
assert config["max_files_per_request"] == 20
|
|
assert config["max_concurrent_upload_requests"] == 2
|
|
assert config["max_request_bytes"] is None
|
|
|
|
|
|
def test_upload_optional_total_budget_is_enforced_without_content_length_dependency(client):
|
|
"""启用总预算后按实际读取字节拒绝超限请求,而不是信任 Content-Length。"""
|
|
test_client, _ = client
|
|
settings = test_client.app.state.settings
|
|
previous = settings.max_request_bytes
|
|
settings.max_request_bytes = len(png_bytes())
|
|
try:
|
|
response = test_client.post(
|
|
"/images/upload",
|
|
files=[
|
|
("files", ("budget-a.png", png_bytes("red"), "image/png")),
|
|
("files", ("budget-b.png", png_bytes("blue"), "image/png")),
|
|
],
|
|
)
|
|
finally:
|
|
settings.max_request_bytes = previous
|
|
assert response.status_code == 413
|
|
assert response.json()["error"] == "request_too_large"
|
|
assert test_client.get("/images").json()["total"] == 0
|
|
|
|
|
|
def test_upload_retry_reuses_durable_meme_operation_and_processing_job(client):
|
|
"""响应丢失后的同内容重试只返回原 Meme 和处理 job,不重复 durable 事实。"""
|
|
test_client, _ = client
|
|
content = png_bytes("red")
|
|
first = test_client.post("/images/upload", files=[("files", ("idempotent.png", content, "image/png"))])
|
|
assert first.status_code == 200
|
|
first_item = first.json()["results"][0]
|
|
engine = test_client.app.state.database.engine
|
|
with engine.connect() as connection:
|
|
before = (
|
|
connection.execute(select(Meme).where(Meme.scope_id == "local")).all(),
|
|
connection.execute(select(StorageOperation).where(StorageOperation.scope_id == "local")).all(),
|
|
connection.execute(select(ImageProcessingJob).where(ImageProcessingJob.scope_id == "local")).all(),
|
|
)
|
|
second = test_client.post("/images/upload", files=[("files", ("idempotent.png", content, "image/png"))])
|
|
assert second.status_code == 200
|
|
second_item = second.json()["results"][0]
|
|
assert second_item["ok"] is True
|
|
assert second_item["idempotent"] is True
|
|
assert second_item["meme_id"] == first_item["meme_id"]
|
|
with engine.connect() as connection:
|
|
after = (
|
|
connection.execute(select(Meme).where(Meme.scope_id == "local")).all(),
|
|
connection.execute(select(StorageOperation).where(StorageOperation.scope_id == "local")).all(),
|
|
connection.execute(select(ImageProcessingJob).where(ImageProcessingJob.scope_id == "local")).all(),
|
|
)
|
|
assert tuple(len(items) for items in after) == tuple(len(items) for items in before)
|
|
|
|
|
|
def test_upload_retry_reports_reconciliation_when_file_fact_changes(client):
|
|
"""数据库记录与实际文件指纹不一致时不盲目认领上传。"""
|
|
test_client, tmp_path = client
|
|
content = png_bytes("red")
|
|
first = test_client.post("/images/upload", files=[("files", ("reconcile.png", content, "image/png"))])
|
|
assert first.status_code == 200
|
|
with test_client.app.state.database.environment("local") as environment:
|
|
record = environment.memes.get(first.json()["results"][0]["meme_id"])
|
|
assert record is not None
|
|
physical_image = test_client.app.state.metadata.blob_store.resolve(record.storage_key)
|
|
physical_image.write_bytes(png_bytes("blue"))
|
|
response = test_client.post("/images/upload", files=[("files", ("reconcile.png", content, "image/png"))])
|
|
assert response.status_code == 200
|
|
assert response.json()["results"][0]["error"] == "upload_reconciliation_required"
|
|
|
|
|
|
def test_multi_upload_seals_visual_batch(client):
|
|
"""多图上传必须把视觉任务登记到批次后再封口,不能返回 batch_not_found。"""
|
|
test_client, _ = client
|
|
response = test_client.post(
|
|
"/images/upload",
|
|
files=[
|
|
("files", ("batch-a.png", png_bytes("red"), "image/png")),
|
|
("files", ("batch-b.png", png_bytes("blue"), "image/png")),
|
|
],
|
|
)
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["batch_id"]
|
|
assert all(item["ok"] and item["visual_task_id"] for item in payload["results"])
|
|
|
|
|
|
def test_image_metadata_returns_complete_database_record(client):
|
|
"""图片库详情接口只返回受控 Meme 对应的完整数据库元数据。"""
|
|
test_client, _ = client
|
|
response = test_client.post("/images/upload", files=[("files", ("detail.png", png_bytes(), "image/png"))])
|
|
assert response.status_code == 200
|
|
meme_id = response.json()["results"][0]["meme_id"]
|
|
with test_client.app.state.database.environment("local") as environment:
|
|
record = environment.memes.get(meme_id)
|
|
assert record is not None
|
|
image = test_client.app.state.metadata.blob_store.resolve(record.storage_key)
|
|
test_client.app.state.metadata.update_context(image, {"summary": "详情摘要", "keywords": ["测试"]}, producer="human", status="ready")
|
|
|
|
metadata = test_client.get("/images/metadata", params={"meme_id": meme_id})
|
|
assert metadata.status_code == 200
|
|
payload = metadata.json()
|
|
assert payload["image"]["relative_path"] == "detail.png"
|
|
assert payload["context_status"] == "ready"
|
|
assert payload["meme_context"]["summary"] == "详情摘要"
|
|
assert payload["meme_context"]["keywords"] == ["测试"]
|
|
|
|
|
|
def test_image_metadata_requires_stable_id_and_reports_unknown_meme(client):
|
|
"""详情接口拒绝旧路径参数,并对未知 Meme 返回稳定错误。"""
|
|
test_client, _ = client
|
|
assert test_client.get("/images/metadata", params={"filename": "missing.png"}).json()["error"] == "meme_id_required"
|
|
response = test_client.get("/images/metadata", params={"meme_id": str(uuid4())})
|
|
assert response.status_code == 404
|
|
assert response.json()["error"] == "meme_not_found"
|
|
|
|
|
|
def test_batch_upload_keeps_success_and_reports_failure(client):
|
|
"""批量上传允许部分成功且按内容身份幂等复用重复文件。"""
|
|
test_client, _ = client
|
|
response = test_client.post(
|
|
"/images/upload",
|
|
files=[
|
|
("files", ("good.png", png_bytes(), "image/png")),
|
|
("files", ("bad.png", b"not-an-image", "image/png")),
|
|
("files", ("note.txt", b"text", "text/plain")),
|
|
],
|
|
)
|
|
results = response.json()["results"]
|
|
assert [item["ok"] for item in results] == [True, False, False]
|
|
duplicate = test_client.post("/images/upload", files=[("files", ("good-copy.png", png_bytes(), "image/png"))])
|
|
duplicate_item = duplicate.json()["results"][0]
|
|
assert duplicate_item["ok"] is True
|
|
assert duplicate_item["idempotent"] is True
|
|
assert duplicate_item["meme_id"] == results[0]["meme_id"]
|
|
|
|
|
|
def test_upload_queues_context_job_and_removed_vlm_routes_are_not_found(client):
|
|
"""上传创建异步语境任务,原 VLM 路由不再暴露。"""
|
|
test_client, _ = client
|
|
response = test_client.post("/images/upload", files=[("files", ("pending.png", png_bytes(), "image/png"))])
|
|
item = response.json()["results"][0]
|
|
assert item["metadata_job_id"]
|
|
assert test_client.get(f"/tasks/{item['metadata_job_id']}").status_code == 200
|
|
assert test_client.post("/images/describe", json={"filename": "pending.png"}).status_code == 404
|
|
assert test_client.post("/images/label-batch", json={"items": []}).status_code == 404
|
|
|
|
|
|
def test_collection_import_starts_with_visual_task(client):
|
|
"""合集导入后的新 Meme 必须先进入视觉任务,而不是直接进入 Agent。"""
|
|
test_client, _ = client
|
|
response = test_client.post("/collections/import", files={"file": ("import.zip", collection_zip_bytes(), "application/zip")})
|
|
assert response.status_code == 200
|
|
item = response.json()["results"][0]
|
|
assert item["ok"] is True
|
|
assert item["visual_task_id"] == item["metadata_job_id"]
|
|
task = test_client.get(f"/tasks/{item['visual_task_id']}")
|
|
assert task.status_code == 200
|
|
assert task.json()["task_type"] == "visual_embedding_generation"
|
|
|
|
|
|
def test_removed_directory_contract_and_rename_conflicts(client):
|
|
"""目录接口永久删除,展示名可以重复且物理内容仍独立。"""
|
|
test_client, _ = client
|
|
assert test_client.get("/images/directories").status_code == 404
|
|
assert test_client.post("/images/directories", json={"name": "work"}).status_code == 404
|
|
one = test_client.post("/images/upload", files=[("files", ("one.png", png_bytes(), "image/png"))]).json()["results"][0]
|
|
two = test_client.post("/images/upload", files=[("files", ("two.png", png_bytes("blue"), "image/png"))]).json()["results"][0]
|
|
renamed_duplicate = test_client.post("/images/rename", json={"meme_id": one["meme_id"], "new_name": "two"})
|
|
assert renamed_duplicate.status_code == 200
|
|
assert renamed_duplicate.json()["filename"] == "two.png"
|
|
renamed = test_client.post("/images/rename", json={"meme_id": one["meme_id"], "new_name": "first"})
|
|
assert renamed.status_code == 200
|
|
assert renamed.json()["meme_id"] == one["meme_id"]
|
|
assert renamed.json()["filename"] == "first.png"
|
|
assert test_client.get(f"/media/{one['meme_id']}").status_code == 200
|
|
assert test_client.get(f"/media/{two['meme_id']}").status_code == 200
|
|
with test_client.app.state.database.environment("local") as environment:
|
|
first_record = environment.memes.get(one["meme_id"])
|
|
second_record = environment.memes.get(two["meme_id"])
|
|
assert first_record is not None and second_record is not None
|
|
assert first_record.storage_key != second_record.storage_key
|
|
|
|
|
|
def test_delete_removes_image_and_database_record(client):
|
|
"""删除接口会移除数据库 Meme、文件和受控媒体访问。"""
|
|
test_client, tmp_path = client
|
|
uploaded = test_client.post("/images/upload", files=[("files", ("remove.png", png_bytes(), "image/png"))]).json()["results"][0]
|
|
meme_id = uploaded["meme_id"]
|
|
response = test_client.post("/images/delete", json={"meme_id": meme_id})
|
|
assert response.status_code == 200
|
|
assert not (tmp_path / "images" / "remove.png").exists()
|
|
assert test_client.get(f"/media/{meme_id}").status_code == 404
|
|
assert test_client.get("/images").json()["total"] == 0
|
|
|
|
|
|
def test_metadata_repair_is_pollable(client):
|
|
"""元数据修复任务报告数据库与文件完整性问题。"""
|
|
test_client, tmp_path = client
|
|
image = tmp_path / "images" / "legacy.png"
|
|
image.write_bytes(png_bytes())
|
|
response = test_client.post("/images/metadata/repair")
|
|
assert response.status_code == 202
|
|
task_id = response.json()["task_id"]
|
|
for _ in range(50):
|
|
status = test_client.get(f"/tasks/{task_id}").json()
|
|
if status["status"] in {"succeeded", "failed"}:
|
|
break
|
|
assert status["status"] == "succeeded"
|
|
assert status["result"]["processed"] == 0
|
|
assert "legacy.png" in status["result"]["orphan_files"]
|
|
second = test_client.post("/images/metadata/repair")
|
|
assert second.status_code == 202
|
|
|
|
|
|
def test_path_traversal_and_symlink_escape_are_blocked(client):
|
|
"""目录穿越和指向根目录外的符号链接均不可读取。"""
|
|
test_client, tmp_path = client
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "secret.png").write_bytes(png_bytes())
|
|
(tmp_path / "images" / "escape").symlink_to(outside, target_is_directory=True)
|
|
traversal = test_client.get("/images", params={"directory": "../outside"})
|
|
assert traversal.status_code == 400
|
|
escaped = test_client.get("/media/escape/secret.png")
|
|
assert escaped.status_code == 404
|
|
|
|
|
|
def test_unknown_task_has_stable_error(client):
|
|
"""未知或重启后丢失的任务返回 task_not_found。"""
|
|
test_client, _ = client
|
|
response = test_client.get("/tasks/unknown")
|
|
assert response.status_code == 404
|
|
assert response.json()["error"] == "task_not_found"
|
|
|
|
|
|
def test_task_list_filters_and_does_not_expose_payload(client):
|
|
"""持久任务列表支持类型筛选且不会返回内部 payload。"""
|
|
test_client, _ = client
|
|
submitted = test_client.post("/generate-cache")
|
|
assert submitted.status_code == 202
|
|
response = test_client.get("/tasks", params={"task_type": "cache_generation", "limit": 1})
|
|
assert response.status_code == 200
|
|
items = response.json()["items"]
|
|
assert len(items) == 1
|
|
assert items[0]["task_type"] == "cache_generation"
|
|
assert "payload" not in items[0]
|
|
|
|
|
|
def test_search_maps_results_to_media_urls(client):
|
|
"""正常检索只返回受控媒体 URL 且不重复。"""
|
|
test_client, _ = client
|
|
uploaded = test_client.post("/images/upload", files=[("files", ("a.png", png_bytes(), "image/png"))]).json()["results"][0]
|
|
meme_id = uploaded["meme_id"]
|
|
|
|
class FakeSearch:
|
|
def has_cache(self):
|
|
return True
|
|
|
|
def search(self, *args, **kwargs):
|
|
return [meme_id, meme_id, str(uuid4())]
|
|
|
|
test_client.app.state.search_engine = FakeSearch()
|
|
response = test_client.post("/search", json={"query": "x", "n_results": 3})
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"results": [f"/media/{meme_id}"],
|
|
"result_media": [
|
|
{
|
|
"meme_id": meme_id,
|
|
"media_url": f"/media/{meme_id}",
|
|
"thumbnail": {"status": "pending", "media_url": None},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def test_llm_failure_falls_back_to_original_query(client):
|
|
"""LLM 增强异常时仍执行一次普通检索。"""
|
|
test_client, _ = client
|
|
meme_id = test_client.post("/images/upload", files=[("files", ("a.png", png_bytes(), "image/png"))]).json()["results"][0]["meme_id"]
|
|
calls = []
|
|
|
|
class FakeSearch:
|
|
def has_cache(self):
|
|
return True
|
|
|
|
def search(self, query, *args, **kwargs):
|
|
calls.append((query, kwargs.get("use_llm")))
|
|
if kwargs.get("use_llm"):
|
|
raise RuntimeError("llm unavailable")
|
|
return [meme_id]
|
|
|
|
test_client.app.state.search_engine = FakeSearch()
|
|
response = test_client.post("/search", json={"query": "原始查询", "llm_enhance": True})
|
|
assert response.status_code == 200
|
|
assert calls == [("原始查询", True), ("原始查询", False)]
|
|
|
|
|
|
def test_cache_generation_returns_pollable_task(client):
|
|
"""缓存生成通过 202 和任务状态接口返回结果。"""
|
|
test_client, _ = client
|
|
|
|
class FakeSearch:
|
|
def generate_cache(self, progress):
|
|
progress(1.0, "done")
|
|
|
|
def has_cache(self):
|
|
return False
|
|
|
|
test_client.app.state.search_engine = FakeSearch()
|
|
response = test_client.post("/generate-cache")
|
|
assert response.status_code == 202
|
|
task_id = response.json()["task_id"]
|
|
for _ in range(50):
|
|
status = test_client.get(f"/tasks/{task_id}").json()
|
|
if status["status"] in {"succeeded", "failed"}:
|
|
break
|
|
assert status["status"] == "succeeded"
|
|
|
|
|
|
def test_parallel_context_batch_writes_independent_database_records_and_incremental_vectors(tmp_path, monkeypatch):
|
|
"""并发度为 2 时两张图片独立完成逐图文本向量,且不隐式创建全库缓存任务。"""
|
|
monkeypatch.setenv("MEMEMEOW_DATABASE_URL", os.getenv("MEMEMEOW_TEST_DATABASE_URL", ""))
|
|
monkeypatch.setenv("MEMEMEOW_DATA_ROOT", str(tmp_path / "data"))
|
|
monkeypatch.setenv("MEMEMEOW_IMAGE_ROOT", str(tmp_path / "images"))
|
|
monkeypatch.setenv("EMBEDDING_API_KEY", "")
|
|
monkeypatch.setenv("EMBEDDING_BASE_URL", "")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_MODEL", "")
|
|
monkeypatch.setenv("MEMEMEOW_AGENT_CALLBACK_SECRET", "test-agent-callback-secret-1234")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_CONCURRENCY", "2")
|
|
monkeypatch.setenv("MEMEMEOW_AGENT_SCOPE_CONCURRENCY", "2")
|
|
_clear_test_scope()
|
|
with TestClient(app) as test_client:
|
|
def hold_schedule(_worker, _job_id):
|
|
"""在测试替换视觉和 Agent 依赖前暂缓上传产生的 Job。"""
|
|
return None
|
|
|
|
original_schedule = ImageProcessingWorker.schedule
|
|
monkeypatch.setattr(ImageProcessingWorker, "schedule", hold_schedule)
|
|
first = test_client.post("/images/upload", files=[("files", ("parallel-a.png", png_bytes("red"), "image/png"))]).json()["results"][0]
|
|
second = test_client.post("/images/upload", files=[("files", ("parallel-b.png", png_bytes("blue"), "image/png"))]).json()["results"][0]
|
|
monkeypatch.setattr(ImageProcessingWorker, "schedule", original_schedule)
|
|
started = threading.Barrier(2)
|
|
sessions: list[str] = []
|
|
|
|
def fake_embed(content, *, filename="image"):
|
|
"""模拟视觉服务,使并发断言只覆盖任务调度和 Agent 写回。"""
|
|
del content, filename
|
|
settings = test_client.app.state.settings
|
|
return {
|
|
"model": settings.visual_model,
|
|
"dimensions": settings.visual_model_dimensions,
|
|
"preprocess_version": settings.visual_preprocess_version,
|
|
"embedding": [1.0] + [0.0] * (settings.visual_model_dimensions - 1),
|
|
}
|
|
|
|
monkeypatch.setattr(test_client.app.state.visual_inference, "embed", fake_embed)
|
|
|
|
def fake_text_embedding(text):
|
|
"""模拟文本 embedding,使统一 job 可以完成最后阶段。"""
|
|
del text
|
|
return [1.0] + [0.0] * 1023
|
|
|
|
monkeypatch.setattr(test_client.app.state.search_engine, "_embedding", fake_text_embedding)
|
|
|
|
def fake_run(image, progress, *, task_id=None, **kwargs):
|
|
"""模拟两个独立 Agent session,并在 barrier 处确认真正并行。"""
|
|
del progress, kwargs
|
|
assert task_id
|
|
sessions.append(image.name)
|
|
started.wait(timeout=2)
|
|
return {
|
|
"title": image.stem.replace("-", " "),
|
|
"summary": "并发测试",
|
|
"subjects": [image.name],
|
|
"visible_text": [],
|
|
"references": [],
|
|
"meaning": None,
|
|
"keywords": ["测试"],
|
|
"search_queries": [],
|
|
"uncertainties": [],
|
|
"source_urls": [],
|
|
}, f"session-{image.stem}"
|
|
|
|
monkeypatch.setattr(test_client.app.state.opencode, "run", fake_run)
|
|
submitted = test_client.post("/images/context/batch", json={"items": [{"meme_id": first["meme_id"]}, {"meme_id": second["meme_id"]}], "include_unready": True})
|
|
assert submitted.status_code == 200
|
|
task_ids = [item["task_id"] for item in submitted.json()["results"]]
|
|
assert len(task_ids) == 2
|
|
with test_client.app.state.database.environment("local") as environment:
|
|
expected_sessions = {
|
|
environment.memes.get(first["meme_id"]).storage_key,
|
|
environment.memes.get(second["meme_id"]).storage_key,
|
|
}
|
|
deadline = time.monotonic() + 5
|
|
while len(sessions) < 2 and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
assert set(sessions) == expected_sessions
|
|
for task_id in task_ids:
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline:
|
|
record = test_client.get(f"/tasks/{task_id}").json()
|
|
if record["status"] in {"succeeded", "failed"}:
|
|
break
|
|
time.sleep(0.01)
|
|
assert record["status"] == "succeeded"
|
|
assert test_client.get(f"/images/metadata?meme_id={first['meme_id']}").json()["context_status"] == "ready"
|
|
assert test_client.get(f"/images/metadata?meme_id={second['meme_id']}").json()["context_status"] == "ready"
|
|
text_items = test_client.get("/tasks", params={"task_type": "text_embedding_generation"}).json()["items"]
|
|
assert len(text_items) == 2
|
|
assert all(item["status"] == "succeeded" for item in text_items)
|
|
cache_items = test_client.get("/tasks", params={"task_type": "cache_generation"}).json()["items"]
|
|
assert cache_items == []
|
|
_clear_test_scope()
|
|
|
|
|
|
def test_pending_agent_ready_sidecar_and_v4_embedding_pipeline_is_backend_owned(tmp_path, monkeypatch):
|
|
"""测试图片从 pending 经 Agent 到 ready/v4 索引,canonical 写回始终由后端掌控。"""
|
|
monkeypatch.setenv("MEMEMEOW_DATABASE_URL", os.getenv("MEMEMEOW_TEST_DATABASE_URL", ""))
|
|
monkeypatch.setenv("MEMEMEOW_DATA_ROOT", str(tmp_path / "data"))
|
|
monkeypatch.setenv("MEMEMEOW_IMAGE_ROOT", str(tmp_path / "images"))
|
|
monkeypatch.setenv("EMBEDDING_API_KEY", "")
|
|
monkeypatch.setenv("EMBEDDING_BASE_URL", "")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_MODEL", "mememeow/gpt-5.6-luna")
|
|
monkeypatch.setenv("MEMEMEOW_AGENT_CALLBACK_SECRET", "test-agent-callback-secret-1234")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_CONCURRENCY", "1")
|
|
_clear_test_scope()
|
|
|
|
|
|
application_file = Path(__file__).resolve().parent.parent / "api.py"
|
|
application_before = application_file.read_bytes()
|
|
with TestClient(app) as test_client:
|
|
def fake_visual_embed(content, *, filename="image"):
|
|
"""模拟视觉服务,使测试只验证逐图任务衔接。"""
|
|
del content, filename
|
|
settings = test_client.app.state.settings
|
|
return {
|
|
"model": settings.visual_model,
|
|
"dimensions": settings.visual_model_dimensions,
|
|
"preprocess_version": settings.visual_preprocess_version,
|
|
"embedding": [1.0] + [0.0] * (settings.visual_model_dimensions - 1),
|
|
}
|
|
|
|
def fake_text_embedding(text):
|
|
"""模拟 v4/逐图文本 embedding 服务并保留固定维度。"""
|
|
assert text
|
|
return [1.0] + [0.0] * 1023
|
|
|
|
def fake_agent_run(image, progress, *, task_id=None, callback_token=None, **kwargs):
|
|
"""模拟 Agent 只交付任务 artifact,拒绝把伪造字段写入 canonical 数据。"""
|
|
del progress, kwargs
|
|
assert task_id and callback_token
|
|
assert callback_token != "test-agent-callback-secret-1234"
|
|
binding = test_client.app.state.callback_verifier.verify(callback_token)
|
|
remaining = binding.expires_at - datetime.now(UTC)
|
|
assert timedelta(hours=1, minutes=59) < remaining <= timedelta(hours=2)
|
|
runner = test_client.app.state.opencode
|
|
draft_path, result_path = runner.create_task_result_paths(task_id)
|
|
candidate = {
|
|
"title": "pending agent",
|
|
"summary": "后端拥有 canonical 写回",
|
|
"subjects": ["测试图片"],
|
|
"visible_text": [],
|
|
"references": [],
|
|
"meaning": None,
|
|
"keywords": ["测试"],
|
|
"search_queries": [],
|
|
"uncertainties": [],
|
|
"source_urls": [],
|
|
"canonical_sidecar": "agent cannot set this",
|
|
"application_code": "agent cannot set this",
|
|
}
|
|
# 运行器只接受任务专属结果文件;canonical sidecar 和应用代码不在
|
|
# Agent artifact 路径中,最终写回由 context_handler 的白名单完成。
|
|
draft_path.write_text(json.dumps(candidate, ensure_ascii=False), encoding="utf-8")
|
|
draft_path.replace(result_path)
|
|
# 这里直接返回候选,专门覆盖后端 canonical 写回的字段白名单;结果
|
|
# 文件读取器对未知顶层字段会严格拒绝,该边界由 OpenCode 单测覆盖。
|
|
return candidate, "pending-session"
|
|
|
|
monkeypatch.setattr(test_client.app.state.visual_inference, "embed", fake_visual_embed)
|
|
monkeypatch.setattr(test_client.app.state.search_engine, "_embedding", fake_text_embedding)
|
|
monkeypatch.setattr(test_client.app.state.opencode, "run", fake_agent_run)
|
|
|
|
uploaded = test_client.post(
|
|
"/images/upload",
|
|
files=[("files", ("pending-agent.png", png_bytes("purple"), "image/png"))],
|
|
)
|
|
assert uploaded.status_code == 200
|
|
result = uploaded.json()["results"][0]
|
|
meme_id = result["meme_id"]
|
|
assert result["metadata_status"] == "pending"
|
|
assert application_file.read_bytes() == application_before
|
|
|
|
processing_id = result["processing_job_id"]
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
processing = test_client.get(f"/images/processing/{processing_id}").json()
|
|
if processing["status"] in {"succeeded", "failed", "blocked", "unknown_execution"}:
|
|
break
|
|
time.sleep(0.02)
|
|
assert processing["status"] == "succeeded"
|
|
assert [stage["stage"] for stage in processing["stages"]] == ["visual", "agent", "auto_rename", "text_embedding"]
|
|
assert [stage["status"] for stage in processing["stages"]] == ["succeeded", "succeeded", "skipped", "succeeded"]
|
|
|
|
metadata = test_client.get("/images/metadata", params={"meme_id": meme_id})
|
|
assert metadata.status_code == 200
|
|
metadata_payload = metadata.json()
|
|
assert metadata_payload["context_status"] == "ready"
|
|
assert metadata_payload["meme_context"]["title"] == "pending agent"
|
|
assert "canonical_sidecar" not in metadata_payload["meme_context"]
|
|
assert "application_code" not in metadata_payload["meme_context"]
|
|
assert application_file.read_bytes() == application_before
|
|
|
|
text_tasks = test_client.get("/tasks", params={"task_type": "text_embedding_generation"}).json()["items"]
|
|
assert len(text_tasks) == 1
|
|
assert text_tasks[0]["status"] == "succeeded"
|
|
cache_submission = test_client.post("/generate-cache")
|
|
assert cache_submission.status_code == 202
|
|
cache_task_id = cache_submission.json()["task_id"]
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
cache_task = test_client.get(f"/tasks/{cache_task_id}").json()
|
|
if cache_task["status"] in {"succeeded", "failed"}:
|
|
break
|
|
time.sleep(0.02)
|
|
assert cache_task["status"] == "succeeded"
|
|
assert cache_task["result"]["indexed_count"] == 1
|
|
assert cache_task["result"]["model"] == test_client.app.state.settings.embedding_model
|
|
assert application_file.read_bytes() == application_before
|
|
_clear_test_scope()
|
|
|
|
|
|
def test_auto_name_pipeline_renames_safely_and_freezes_final_metadata_hash(tmp_path, monkeypatch):
|
|
"""自动命名四阶段必须按顺序完成,并让文本 Task 绑定重命名后的 metadata hash。"""
|
|
monkeypatch.setenv("MEMEMEOW_DATABASE_URL", os.getenv("MEMEMEOW_TEST_DATABASE_URL", ""))
|
|
monkeypatch.setenv("MEMEMEOW_DATA_ROOT", str(tmp_path / "data"))
|
|
monkeypatch.setenv("MEMEMEOW_IMAGE_ROOT", str(tmp_path / "images"))
|
|
monkeypatch.setenv("EMBEDDING_API_KEY", "")
|
|
monkeypatch.setenv("EMBEDDING_BASE_URL", "")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_MODEL", "mememeow/gpt-5.6-luna")
|
|
monkeypatch.setenv("MEMEMEOW_AGENT_CALLBACK_SECRET", "test-agent-callback-secret-1234")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_CONCURRENCY", "1")
|
|
_clear_test_scope()
|
|
with TestClient(app) as test_client:
|
|
def fake_visual_embed(content, *, filename="image"):
|
|
"""模拟视觉服务,确保测试只验证图片处理控制面。"""
|
|
del content, filename
|
|
settings = test_client.app.state.settings
|
|
return {
|
|
"model": settings.visual_model,
|
|
"dimensions": settings.visual_model_dimensions,
|
|
"preprocess_version": settings.visual_preprocess_version,
|
|
"embedding": [1.0] + [0.0] * (settings.visual_model_dimensions - 1),
|
|
}
|
|
|
|
def fake_text_embedding(text_value):
|
|
"""模拟固定维度文本向量,避免访问外部 embedding 服务。"""
|
|
assert text_value
|
|
return [1.0] + [0.0] * 1023
|
|
|
|
def fake_agent_run(image, progress, *, task_id=None, callback_token=None, **kwargs):
|
|
"""交付带非法路径字符的标题,验证服务端安全派生文件名。"""
|
|
del progress, callback_token, kwargs
|
|
assert task_id
|
|
runner = test_client.app.state.opencode
|
|
draft_path, result_path = runner.create_task_result_paths(task_id)
|
|
draft_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"title": "Project Launch v2",
|
|
"summary": "自动命名测试",
|
|
"subjects": ["测试图片"],
|
|
"visible_text": [],
|
|
"references": [],
|
|
"meaning": None,
|
|
"keywords": ["测试"],
|
|
"search_queries": [],
|
|
"uncertainties": [],
|
|
"source_urls": [],
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
draft_path.replace(result_path)
|
|
return runner.read_result_file(result_path), f"session-{image.stem}"
|
|
|
|
monkeypatch.setattr(test_client.app.state.visual_inference, "embed", fake_visual_embed)
|
|
monkeypatch.setattr(test_client.app.state.search_engine, "_embedding", fake_text_embedding)
|
|
monkeypatch.setattr(test_client.app.state.opencode, "run", fake_agent_run)
|
|
|
|
uploaded = test_client.post(
|
|
"/images/upload",
|
|
data={"auto_name": "true", "reverse_image_policy": "forbid"},
|
|
files=[("files", ("original-name.png", png_bytes("purple"), "image/png"))],
|
|
)
|
|
assert uploaded.status_code == 200
|
|
result = uploaded.json()["results"][0]
|
|
assert result["ok"] is True
|
|
assert result["auto_name"] is True
|
|
assert result["saved_filename"] == "original-name.png"
|
|
job_id = result["processing_job_id"]
|
|
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
processing = test_client.get(f"/images/processing/{job_id}").json()
|
|
if processing.get("status") in {"succeeded", "failed", "blocked", "unknown_execution"}:
|
|
break
|
|
time.sleep(0.02)
|
|
assert processing["status"] == "succeeded"
|
|
assert processing["auto_name"] is True
|
|
assert [stage["stage"] for stage in processing["stages"]] == ["visual", "agent", "auto_rename", "text_embedding"]
|
|
assert [stage["status"] for stage in processing["stages"]] == ["succeeded", "succeeded", "succeeded", "succeeded"]
|
|
assert processing["has_warnings"] is False
|
|
|
|
metadata = test_client.get("/images/metadata", params={"meme_id": result["meme_id"]}).json()
|
|
assert metadata["image"]["relative_path"] == "Project Launch v2.png"
|
|
assert not (tmp_path / "images" / "original-name.png").exists()
|
|
|
|
with test_client.app.state.database.environment("local") as environment:
|
|
meme = environment.memes.get(result["meme_id"])
|
|
assert meme is not None
|
|
assert meme.display_name == "Project Launch v2"
|
|
physical_image = tmp_path / "images" / meme.storage_key
|
|
assert physical_image.exists()
|
|
assert not (tmp_path / "images" / "Project Launch v2.png").exists()
|
|
final_hash = ImageProcessingWorker._metadata_hash(meme)
|
|
assert final_hash is not None
|
|
rename_task = environment.tasks.get(
|
|
next(
|
|
stage["task_id"]
|
|
for stage in processing["stages"]
|
|
if stage["stage"] == "auto_rename"
|
|
)
|
|
)
|
|
assert rename_task is not None
|
|
assert rename_task.status == "succeeded"
|
|
assert "auto_name" not in rename_task.payload
|
|
text_task = environment.uow.session.scalar(
|
|
select(Task).where(
|
|
Task.scope_id == "local",
|
|
Task.task_type == "text_embedding_generation",
|
|
Task.processing_job_id == job_id,
|
|
)
|
|
)
|
|
assert text_task is not None
|
|
assert text_task.payload["metadata_hash"] == final_hash
|
|
old_hash_rows = list(
|
|
environment.uow.session.scalars(
|
|
select(MemeTextEmbedding).where(
|
|
MemeTextEmbedding.scope_id == "local",
|
|
MemeTextEmbedding.meme_id == meme.id,
|
|
MemeTextEmbedding.metadata_hash == final_hash,
|
|
MemeTextEmbedding.status == "ready",
|
|
)
|
|
)
|
|
)
|
|
assert len(old_hash_rows) == 1
|
|
|
|
_clear_test_scope()
|
|
|
|
|
|
def test_auto_name_warning_keeps_leaf_failure_and_continues_text_stage(tmp_path, monkeypatch):
|
|
"""缺少标题时叶子 Task 必须失败为事实,父 Job 以 warning 继续文本阶段。"""
|
|
monkeypatch.setenv("MEMEMEOW_DATABASE_URL", os.getenv("MEMEMEOW_TEST_DATABASE_URL", ""))
|
|
monkeypatch.setenv("MEMEMEOW_DATA_ROOT", str(tmp_path / "data"))
|
|
monkeypatch.setenv("MEMEMEOW_IMAGE_ROOT", str(tmp_path / "images"))
|
|
monkeypatch.setenv("EMBEDDING_API_KEY", "")
|
|
monkeypatch.setenv("EMBEDDING_BASE_URL", "")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_MODEL", "mememeow/gpt-5.6-luna")
|
|
monkeypatch.setenv("MEMEMEOW_AGENT_CALLBACK_SECRET", "test-agent-callback-secret-1234")
|
|
_clear_test_scope()
|
|
|
|
|
|
with TestClient(app) as test_client:
|
|
def fake_visual_embed(content, *, filename="image"):
|
|
"""模拟视觉向量服务。"""
|
|
del content, filename
|
|
settings = test_client.app.state.settings
|
|
return {
|
|
"model": settings.visual_model,
|
|
"dimensions": settings.visual_model_dimensions,
|
|
"preprocess_version": settings.visual_preprocess_version,
|
|
"embedding": [1.0] + [0.0] * (settings.visual_model_dimensions - 1),
|
|
}
|
|
|
|
def fake_text_embedding(text_value):
|
|
"""模拟文本向量服务。"""
|
|
assert text_value
|
|
return [1.0] + [0.0] * 1023
|
|
|
|
def fake_agent_run(image, progress, *, task_id=None, callback_token=None, **kwargs):
|
|
"""交付没有标题但其余字段有效的 Agent 语境。"""
|
|
del image, progress, callback_token, kwargs
|
|
assert task_id
|
|
runner = test_client.app.state.opencode
|
|
draft_path, result_path = runner.create_task_result_paths(task_id)
|
|
draft_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"title": None,
|
|
"summary": "保留原始文件名",
|
|
"subjects": ["测试图片"],
|
|
"visible_text": [],
|
|
"references": [],
|
|
"meaning": None,
|
|
"keywords": ["测试"],
|
|
"search_queries": [],
|
|
"uncertainties": [],
|
|
"source_urls": [],
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
draft_path.replace(result_path)
|
|
return runner.read_result_file(result_path), "warning-session"
|
|
|
|
monkeypatch.setattr(test_client.app.state.visual_inference, "embed", fake_visual_embed)
|
|
monkeypatch.setattr(test_client.app.state.search_engine, "_embedding", fake_text_embedding)
|
|
monkeypatch.setattr(test_client.app.state.opencode, "run", fake_agent_run)
|
|
uploaded = test_client.post(
|
|
"/images/upload",
|
|
data={"auto_name": "true"},
|
|
files=[("files", ("keep-original.png", png_bytes("blue"), "image/png"))],
|
|
)
|
|
assert uploaded.status_code == 200
|
|
result = uploaded.json()["results"][0]
|
|
job_id = result["processing_job_id"]
|
|
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
processing = test_client.get(f"/images/processing/{job_id}").json()
|
|
if processing.get("status") in {"succeeded", "failed", "blocked", "unknown_execution"}:
|
|
break
|
|
time.sleep(0.02)
|
|
assert processing["status"] == "succeeded"
|
|
assert processing["has_warnings"] is True
|
|
assert processing["warnings"] == [
|
|
{
|
|
"stage": "auto_rename",
|
|
"error": "auto_rename_title_missing",
|
|
"message": "自动重命名未完成",
|
|
"recoverable": True,
|
|
}
|
|
]
|
|
assert [stage["status"] for stage in processing["stages"]] == ["succeeded", "succeeded", "warning", "succeeded"]
|
|
assert test_client.get("/images/metadata", params={"meme_id": result["meme_id"]}).json()["image"]["relative_path"] == "keep-original.png"
|
|
rename_tasks = test_client.get("/tasks", params={"task_type": "image_auto_rename"}).json()["items"]
|
|
assert len(rename_tasks) == 1
|
|
assert rename_tasks[0]["status"] == "failed"
|
|
assert rename_tasks[0]["error"]["error"] == "auto_rename_title_missing"
|
|
assert rename_tasks[0]["image_stage_recoverable"] is True
|
|
text_tasks = test_client.get("/tasks", params={"task_type": "text_embedding_generation"}).json()["items"]
|
|
assert len(text_tasks) == 1 and text_tasks[0]["status"] == "succeeded"
|
|
_clear_test_scope()
|
|
|
|
|
|
def test_scope_unready_processing_is_option_only_and_fail_closed(client, monkeypatch):
|
|
"""scope 级未就绪入口不接受分页筛选,并在能力拒绝前不产生业务副作用。"""
|
|
test_client, _ = client
|
|
# 该测试必须稳定覆盖能力拒绝分支,不能受宿主环境中的 SerpAPI 凭据影响。
|
|
monkeypatch.setattr(test_client.app.state.reverse_image, "_provider_factory", None)
|
|
monkeypatch.setattr(test_client.app.state.reverse_image.settings, "serpapi_api_key", "")
|
|
|
|
unavailable = test_client.post(
|
|
"/images/processing/unready",
|
|
json={"reverse_image_policy": "auto", "auto_name": False},
|
|
)
|
|
assert unavailable.status_code == 503
|
|
assert unavailable.json()["error"] == "reverse_image_unavailable"
|
|
with test_client.app.state.database.environment("local") as environment:
|
|
assert environment.uow.session.scalar(select(Meme.id)) is None
|
|
|
|
first = test_client.post("/images/upload", files=[("files", ("scope-first.png", png_bytes("red"), "image/png"))]).json()["results"][0]
|
|
second = test_client.post("/images/upload", files=[("files", ("scope-second.png", png_bytes("blue"), "image/png"))]).json()["results"][0]
|
|
invalid_bool = test_client.post(
|
|
"/images/processing/unready",
|
|
json={"reverse_image_policy": "forbid", "auto_name": "true"},
|
|
)
|
|
assert invalid_bool.status_code == 400
|
|
assert invalid_bool.json()["error"] == "invalid_request"
|
|
|
|
response = test_client.post(
|
|
"/images/processing/unready",
|
|
json={"reverse_image_policy": "forbid", "auto_name": False},
|
|
)
|
|
assert response.status_code == 202
|
|
body = response.json()
|
|
assert body["target_count"] == 2
|
|
assert {item["meme_id"] for item in body["results"]} == {first["meme_id"], second["meme_id"]}
|
|
assert all(item.get("processing_job_id") or item.get("error") for item in body["results"])
|
|
|
|
filtered = test_client.post(
|
|
"/images/processing/unready?page=2",
|
|
json={"reverse_image_policy": "forbid", "auto_name": False},
|
|
)
|
|
assert filtered.status_code == 400
|
|
assert filtered.json()["error"] == "invalid_request"
|
|
|
|
|
|
def test_selected_stage_batch_validates_core_mapping_and_submits_independent_tasks(client):
|
|
"""选中重试只接受三个核心阶段,并为每个图片阶段创建独立任务。"""
|
|
test_client, _ = client
|
|
uploaded = test_client.post(
|
|
"/images/upload",
|
|
files=[("files", ("selected-stage.png", png_bytes("green"), "image/png"))],
|
|
).json()["results"][0]
|
|
meme_id = uploaded["meme_id"]
|
|
|
|
invalid = test_client.post(
|
|
"/images/stages/batch",
|
|
json={"items": [{"meme_id": meme_id}], "stages": ["auto_rename"]},
|
|
)
|
|
assert invalid.status_code == 422
|
|
assert invalid.json()["error"] == "invalid_image_stage"
|
|
|
|
response = test_client.post(
|
|
"/images/stages/batch",
|
|
json={"items": [{"meme_id": meme_id}], "stages": ["visual", "text_embedding"]},
|
|
)
|
|
assert response.status_code == 202
|
|
body = response.json()
|
|
assert body["target_count"] == 2
|
|
assert {item["stage"] for item in body["results"]} == {"visual", "text_embedding"}
|
|
assert all(item.get("task_id") or item.get("error") for item in body["results"])
|
|
|
|
|
|
def test_standalone_auto_rename_preserves_current_text_embedding_without_creating_job(tmp_path, monkeypatch):
|
|
"""内容寻址图片仅更新展示名时保留语义向量,且不能创建父 Job 或文本任务。"""
|
|
monkeypatch.setenv("MEMEMEOW_DATABASE_URL", os.getenv("MEMEMEOW_TEST_DATABASE_URL", ""))
|
|
monkeypatch.setenv("MEMEMEOW_DATA_ROOT", str(tmp_path / "data"))
|
|
monkeypatch.setenv("MEMEMEOW_IMAGE_ROOT", str(tmp_path / "images"))
|
|
monkeypatch.setenv("EMBEDDING_API_KEY", "")
|
|
monkeypatch.setenv("EMBEDDING_BASE_URL", "")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_MODEL", "")
|
|
_clear_test_scope()
|
|
with TestClient(app) as test_client:
|
|
metadata_service = test_client.app.state.metadata
|
|
meme_id, image = metadata_service.upload_bytes(png_bytes("blue"), target_key="standalone-source.png")
|
|
metadata_service.update_context(
|
|
image,
|
|
{"title": "Standalone renamed", "summary": "独立阶段测试"},
|
|
producer="research",
|
|
model="test-model",
|
|
status="ready",
|
|
)
|
|
with test_client.app.state.database.environment("local") as environment:
|
|
record = environment.memes.get(meme_id)
|
|
assert record is not None
|
|
old_metadata_hash = ImageProcessingWorker._metadata_hash(record)
|
|
assert old_metadata_hash is not None
|
|
old_sha256 = record.sha256
|
|
environment.uow.session.add(
|
|
MemeTextEmbedding(
|
|
scope_id="local",
|
|
meme_id=record.id,
|
|
image_sha256=old_sha256,
|
|
metadata_hash=old_metadata_hash,
|
|
embedding_model_version=test_client.app.state.settings.embedding_model,
|
|
dimensions=EMBEDDING_DIMENSIONS,
|
|
semantic_document="标题:Standalone renamed",
|
|
embedding=[1.0] + [0.0] * (EMBEDDING_DIMENSIONS - 1),
|
|
status="ready",
|
|
)
|
|
)
|
|
environment.uow.session.commit()
|
|
|
|
submitted = test_client.post(
|
|
"/images/stages",
|
|
json={"meme_id": str(meme_id), "stage": "auto_rename"},
|
|
)
|
|
assert submitted.status_code == 202
|
|
task_id = submitted.json()["task_id"]
|
|
deadline = time.monotonic() + 10
|
|
while time.monotonic() < deadline:
|
|
task = test_client.get(f"/tasks/{task_id}").json()
|
|
if task["status"] in {"succeeded", "failed"}:
|
|
break
|
|
time.sleep(0.02)
|
|
assert task["status"] == "succeeded"
|
|
|
|
with test_client.app.state.database.environment("local") as environment:
|
|
record = environment.memes.get(meme_id)
|
|
assert record is not None
|
|
assert record.display_name == "Standalone renamed"
|
|
assert record.storage_key == Path(image).name
|
|
assert Path(image).exists()
|
|
row = environment.uow.session.scalar(
|
|
select(MemeTextEmbedding).where(
|
|
MemeTextEmbedding.scope_id == "local",
|
|
MemeTextEmbedding.meme_id == record.id,
|
|
MemeTextEmbedding.image_sha256 == old_sha256,
|
|
MemeTextEmbedding.metadata_hash == old_metadata_hash,
|
|
)
|
|
)
|
|
assert row is not None
|
|
# storage_key 和七字段语义 hash 均未变化,展示名不是文本向量输入。
|
|
assert row.status == "ready"
|
|
assert environment.uow.session.scalar(select(Task).where(Task.task_type == "text_embedding_generation")) is None
|
|
assert environment.uow.session.scalar(select(Task).where(Task.processing_job_id.is_not(None))) is None
|
|
_clear_test_scope()
|
|
|
|
|
|
def test_context_target_deleted_during_agent_is_reported_as_target_changed(tmp_path, monkeypatch):
|
|
"""Agent 运行期间图片被删除时,任务必须返回稳定的目标变化错误。"""
|
|
monkeypatch.setenv("MEMEMEOW_DATABASE_URL", os.getenv("MEMEMEOW_TEST_DATABASE_URL", ""))
|
|
monkeypatch.setenv("MEMEMEOW_DATA_ROOT", str(tmp_path / "data"))
|
|
monkeypatch.setenv("MEMEMEOW_IMAGE_ROOT", str(tmp_path / "images"))
|
|
monkeypatch.setenv("EMBEDDING_API_KEY", "")
|
|
monkeypatch.setenv("EMBEDDING_BASE_URL", "")
|
|
monkeypatch.setenv("MEMEMEOW_OPENCODE_MODEL", "")
|
|
monkeypatch.setenv("MEMEMEOW_AGENT_CALLBACK_SECRET", "test-agent-callback-secret-1234")
|
|
_clear_test_scope()
|
|
with TestClient(app) as test_client:
|
|
uploaded = test_client.post("/images/upload", files=[("files", ("deleted.png", png_bytes(), "image/png"))]).json()["results"][0]
|
|
|
|
def fake_embed(content, *, filename="image"):
|
|
"""模拟视觉服务,避免目标变化测试访问真实视觉模型。"""
|
|
del content, filename
|
|
settings = test_client.app.state.settings
|
|
return {
|
|
"model": settings.visual_model,
|
|
"dimensions": settings.visual_model_dimensions,
|
|
"preprocess_version": settings.visual_preprocess_version,
|
|
"embedding": [1.0] + [0.0] * (settings.visual_model_dimensions - 1),
|
|
}
|
|
|
|
monkeypatch.setattr(test_client.app.state.visual_inference, "embed", fake_embed)
|
|
|
|
def fake_run(path, progress, *, task_id=None, **kwargs):
|
|
"""模拟 Agent 完成前目标图片被删除。"""
|
|
del progress, kwargs
|
|
assert task_id
|
|
path.unlink()
|
|
return {}, "deleted-session"
|
|
|
|
monkeypatch.setattr(test_client.app.state.opencode, "run", fake_run)
|
|
response = test_client.post("/images/context", json={"meme_id": uploaded["meme_id"]})
|
|
assert response.status_code == 202
|
|
task_id = response.json()["task_id"]
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline:
|
|
status = test_client.get(f"/tasks/{task_id}").json()
|
|
if status["status"] in {"succeeded", "failed"}:
|
|
break
|
|
time.sleep(0.01)
|
|
assert status["status"] == "failed"
|
|
assert status["error"]["error"] == "target_changed"
|
|
_clear_test_scope()
|