mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-28 22:03:46 +08:00
196 lines
8.0 KiB
Python
196 lines
8.0 KiB
Python
"""SerpApi Google Lens Skill 缓存的离线回归测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def lens_module():
|
|
"""从 Skill 目录加载缓存脚本,避免测试依赖全局安装。"""
|
|
root = Path(__file__).resolve().parents[1]
|
|
candidates = (
|
|
root / "skills/research-meme-context/scripts/serpapi_google_lens.py",
|
|
root / ".agents/skills/research-meme-context/scripts/serpapi_google_lens.py",
|
|
)
|
|
script = next((candidate for candidate in candidates if candidate.is_file()), candidates[0])
|
|
assert script.is_file()
|
|
specification = importlib.util.spec_from_file_location("serpapi_google_lens", script)
|
|
assert specification and specification.loader
|
|
module = importlib.util.module_from_spec(specification)
|
|
sys.modules[specification.name] = module
|
|
specification.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def make_request(lens_module, tmp_path: Path):
|
|
"""构造独立的最小图片输入和 Lens 请求。"""
|
|
image = tmp_path / "meme.jpg"
|
|
image.write_bytes(b"not-a-real-jpeg-but-a-stable-test-input")
|
|
return lens_module.LensRequest(image_path=image)
|
|
|
|
|
|
def successful_response(title: str = "候选") -> dict[str, object]:
|
|
"""生成带需脱敏字段的成功响应,验证缓存不会持久化归档标识。"""
|
|
return {
|
|
"search_metadata": {
|
|
"status": "Success",
|
|
"id": "private-search-id",
|
|
"json_endpoint": "https://serpapi.example/private.json",
|
|
"diagnostic": "request used test-key",
|
|
},
|
|
"visual_matches": [
|
|
{
|
|
"title": title,
|
|
"link": "https://example.com/candidate",
|
|
"serpapi_link": "https://serpapi.example/private-link",
|
|
"about_page_serpapi_link": "https://serpapi.example/also-private",
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def test_successful_result_is_reused_and_sanitized(lens_module, tmp_path: Path):
|
|
"""同一图片和参数第二次调用命中缓存,且快照不含 SerpApi 私有标识。"""
|
|
request = make_request(lens_module, tmp_path)
|
|
calls = []
|
|
|
|
def fetch(_request, _api_key):
|
|
calls.append(1)
|
|
return successful_response()
|
|
|
|
first = lens_module.run_lens_search(request, "test-key", tmp_path / "cache", fetch_result=fetch)
|
|
second = lens_module.run_lens_search(request, "test-key", tmp_path / "cache", fetch_result=fetch)
|
|
|
|
assert first["cache"]["status"] == "miss"
|
|
assert second["cache"]["status"] == "hit"
|
|
assert len(calls) == 1
|
|
serialized = json.dumps(second, ensure_ascii=False)
|
|
assert "private-search-id" not in serialized
|
|
assert "private-link" not in serialized
|
|
assert "also-private" not in serialized
|
|
assert "test-key" not in serialized
|
|
|
|
|
|
def test_empty_result_expires_but_successful_refresh_keeps_history(lens_module, tmp_path: Path):
|
|
"""空结果在短期内复用,过期后重新请求;刷新成功时历史快照不会丢失。"""
|
|
request = make_request(lens_module, tmp_path)
|
|
cache_root = tmp_path / "cache"
|
|
started = datetime(2026, 8, 10, tzinfo=UTC)
|
|
calls = []
|
|
|
|
def empty_fetch(_request, _api_key):
|
|
calls.append("empty")
|
|
return {"search_metadata": {"status": "Success"}, "visual_matches": []}
|
|
|
|
first = lens_module.run_lens_search(request, "test-key", cache_root, now=started, fetch_result=empty_fetch)
|
|
second = lens_module.run_lens_search(request, "test-key", cache_root, now=started + timedelta(days=1), fetch_result=empty_fetch)
|
|
third = lens_module.run_lens_search(request, "test-key", cache_root, now=started + timedelta(days=4), fetch_result=lambda *_: successful_response("更新候选"))
|
|
|
|
assert first["cache"]["status"] == "miss"
|
|
assert second["cache"]["status"] == "hit"
|
|
assert third["cache"]["status"] == "refresh"
|
|
assert calls == ["empty"]
|
|
record = next(cache_root.glob("*.json"))
|
|
assert len(json.loads(record.read_text(encoding="utf-8"))["snapshots"]) == 2
|
|
|
|
|
|
def test_failed_provider_result_is_not_cached(lens_module, tmp_path: Path):
|
|
"""失败响应抛出异常且不会写入可被后续任务误复用的缓存。"""
|
|
request = make_request(lens_module, tmp_path)
|
|
cache_root = tmp_path / "cache"
|
|
|
|
with pytest.raises(lens_module.SerpApiError):
|
|
lens_module.run_lens_search(
|
|
request,
|
|
"test-key",
|
|
cache_root,
|
|
fetch_result=lambda *_: {"search_metadata": {"status": "Error"}, "error": "provider failed"},
|
|
)
|
|
|
|
assert not list(cache_root.glob("*.json"))
|
|
|
|
|
|
def test_concurrent_requests_share_one_provider_call(lens_module, tmp_path: Path):
|
|
"""同一缓存键的并发任务在文件锁内二次检查,只有首个任务调用供应商。"""
|
|
request = make_request(lens_module, tmp_path)
|
|
cache_root = tmp_path / "cache"
|
|
calls = []
|
|
|
|
def fetch(_request, _api_key):
|
|
calls.append(1)
|
|
time.sleep(0.05)
|
|
return successful_response()
|
|
|
|
def run():
|
|
return lens_module.run_lens_search(request, "test-key", cache_root, fetch_result=fetch)
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
outputs = list(executor.map(lambda _: run(), range(2)))
|
|
|
|
assert len(calls) == 1
|
|
assert {output["cache"]["status"] for output in outputs} == {"miss", "hit"}
|
|
|
|
|
|
def test_thin_cli_omits_request_id_by_default_but_keeps_legacy_option(lens_module) -> None:
|
|
"""薄 CLI 默认让服务端生成权威 ID,同时保留旧显式参数。"""
|
|
omitted = lens_module.parse_args(["image.png", "--task-id", "task-a"])
|
|
explicit = lens_module.parse_args(["image.png", "--task-id", "task-a", "--request-id", "legacy-id"])
|
|
assert omitted.request_id is None
|
|
assert explicit.request_id == "legacy-id"
|
|
|
|
body, _content_type = lens_module._multipart({"task_id": "task-a"}, b"image", "image.png")
|
|
assert b"name=\"request_id\"" not in body
|
|
|
|
|
|
def test_thin_cli_accepts_images_larger_than_500_kib(lens_module, tmp_path: Path, monkeypatch, capsys) -> None:
|
|
"""薄 CLI 不再把 500 KiB 当作反向图片接口的上限。"""
|
|
image = tmp_path / "image.png"
|
|
image.write_bytes(b"x" * (500 * 1024 + 1))
|
|
monkeypatch.setenv("MEMEMEOW_AGENT_CALLBACK_TOKEN", "task-callback-token")
|
|
|
|
class Response:
|
|
"""最小 HTTP 响应替身。"""
|
|
|
|
def read(self) -> bytes:
|
|
"""返回后端供应商无关响应。"""
|
|
return b"{}"
|
|
|
|
monkeypatch.setattr(lens_module, "urlopen", lambda request, timeout: Response())
|
|
assert lens_module.main([str(image), "--task-id", "task-a"]) == 0
|
|
|
|
|
|
def test_thin_cli_prints_authoritative_response_without_provider_credentials(lens_module, tmp_path: Path, monkeypatch, capsys) -> None:
|
|
"""CLI 原样输出后端权威 request ID,环境只需要 callback token。"""
|
|
image = tmp_path / "image.png"
|
|
image.write_bytes(b"image")
|
|
monkeypatch.setenv("MEMEMEOW_AGENT_CALLBACK_TOKEN", "task-callback-token")
|
|
monkeypatch.setenv("MEMEMEOW_REVERSE_IMAGE_INTERNAL_URL", "http://internal.test/reverse-image/search")
|
|
|
|
class Response:
|
|
"""最小 HTTP 响应替身。"""
|
|
|
|
def read(self) -> bytes:
|
|
"""返回后端供应商无关响应。"""
|
|
return json.dumps({"request_id": "cb-authoritative", "provider": {"called": False}}).encode()
|
|
|
|
def fake_urlopen(request, timeout):
|
|
"""检查 callback 请求只使用 Runner 注入的地址和凭据。"""
|
|
assert request.full_url == "http://internal.test/reverse-image/search"
|
|
assert request.get_header("X-mememeow-callback") == "task-callback-token"
|
|
assert timeout == 60
|
|
assert b'name="request_id"' not in request.data
|
|
return Response()
|
|
|
|
monkeypatch.setattr(lens_module, "urlopen", fake_urlopen)
|
|
assert lens_module.main([str(image), "--task-id", "task-a"]) == 0
|
|
assert json.loads(capsys.readouterr().out)["request_id"] == "cb-authoritative"
|