Files
2026-09-08 19:03:21 +08:00

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()