Files
infstellar 5ecda39843 Add unit tests for image processing worker, operation policy, reverse image provider, and scope handling
- Implement tests for the ImageProcessingWorker to validate task deduplication and policy interactions.
- Create tests for operation policy to ensure proper grant handling and error responses.
- Add tests for reverse image provider response structure to validate empty and malformed results.
- Develop comprehensive tests for scope resolution and service validation to ensure correct behavior in various scenarios.
2026-08-17 12:50:29 +08:00

685 lines
31 KiB
Python

"""FastAPI 公共契约、文件边界和上传行为测试。"""
from __future__ import annotations
import io
import json
import zipfile
import os
import threading
import time
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from sqlalchemy import text
from api import app
from backend.collection_packages import CollectionManifest, CollectionManifestCollection, CollectionManifestMember, serialize_manifest, sha256_bytes
from backend.database import create_engine_for_url
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_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()
@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_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, tmp_path = 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"]
image = tmp_path / "images" / "detail.png"
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.png", png_bytes("blue"), "image/png"))])
assert duplicate.json()["results"][0]["error"] == "file_exists"
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, tmp_path = 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]
conflict = test_client.post("/images/rename", json={"meme_id": one["meme_id"], "new_name": "two"})
assert conflict.status_code == 409
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
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}"]}
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_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")
_clear_test_scope()
with TestClient(app) as test_client:
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]
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
deadline = time.monotonic() + 5
while len(sessions) < 2 and time.monotonic() < deadline:
time.sleep(0.01)
assert set(sessions) == {"parallel-a.png", "parallel-b.png"}
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_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"
runner = test_client.app.state.opencode
draft_path, result_path = runner.create_task_result_paths(task_id)
candidate = {
"title": image.stem.replace("-", " "),
"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)
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",
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", "text_embedding"]
assert all(stage["status"] == "succeeded" for stage in processing["stages"])
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_context_target_deleted_during_agent_is_reported_as_target_changed(tmp_path, monkeypatch):
"""Agent 运行期间图片被删除时,任务必须返回稳定的目标变化错误。"""
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()