feat: complete image console and add verified REST and MCP workflows

This commit is contained in:
2026-11-04 00:41:54 +08:00
parent 3140cd51d2
commit bef8af53ed
48 changed files with 5167 additions and 1176 deletions
+203
View File
@@ -0,0 +1,203 @@
"""隔离数据库、文件和上游的接口回归测试;不使用真实 API Key。"""
import base64
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
_runtime = tempfile.TemporaryDirectory(prefix="ai-image-tests-")
os.environ["AI_IMAGE_DATA_DIR"] = str(Path(_runtime.name) / "data")
os.environ["AI_IMAGE_OUTPUT_DIR"] = str(Path(_runtime.name) / "output")
os.environ["AI_IMAGE_DB"] = str(Path(_runtime.name) / "data" / "test.db")
os.environ["AI_IMAGE_SECRET_KEY"] = "isolated-test-key"
import httpx
from fastapi.testclient import TestClient
from main import dispatcher
from app import config, db
from app.providers import ImageResult, ProviderAdapter, ProviderError
from app.services import generation, providers
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4l8AAAAASUVORK5CYII=")
class AppTests(unittest.TestCase):
def setUp(self):
self.client = TestClient(dispatcher)
with db.session() as conn:
for table in ("generations", "models", "providers"):
conn.execute(f"DELETE FROM {table}")
self.provider = dict(id="test", name="测试供应商", adapter="openai_compat",
base_url="https://upstream.invalid/v1", api_key="test-secret-key")
self.client.post("/api/providers", json=self.provider).raise_for_status()
providers.upsert_models("test", ["test-image"])
self.payload = dict(provider_id="test", model="test-image", prompt="测试图片")
def upstream(self, method, path, **kwargs):
if path == "/models":
return httpx.Response(200, json={"data": [{"id": "test-image"}, {"id": "chat"}]})
return httpx.Response(200, json={"data": [{"b64_json": base64.b64encode(PNG).decode()}]})
def test_pages_and_read_endpoints(self):
for url in ("/", "/create.html", "/providers.html", "/models.html", "/records.html",
"/skills.html", "/api.js", "/components.js", "/styles.css", "/health",
"/api/providers", "/api/models", "/api/generations", "/api/galleries",
"/api/skills", "/api/skills/raw"):
with self.subTest(url=url):
self.assertEqual(self.client.get(url).status_code, 200)
def test_secret_is_encrypted_and_empty_update_keeps_key(self):
result = self.client.post("/api/providers", json={**self.provider, "api_key": ""})
self.assertNotIn("test-secret-key", result.text)
self.assertNotEqual(providers.get_provider_row("test")["api_key"], "test-secret-key")
self.assertEqual(providers.provider_config("test").api_key, "test-secret-key")
def test_discover_preserves_disabled_model(self):
providers.set_model_enabled("test:test-image", False)
with patch.object(ProviderAdapter, "_request", side_effect=self.upstream):
result = self.client.post("/api/providers/test/discover")
self.assertEqual(result.json()["matched"], ["test-image"])
self.assertFalse(providers.get_model("test:test-image")["enabled"])
def test_generation_round_trip(self):
with patch.object(ProviderAdapter, "_request", side_effect=self.upstream):
result = self.client.post("/api/generate", json=self.payload)
self.assertEqual(result.status_code, 200, result.text)
image = result.json()["images"][0]
self.assertEqual(self.client.get(image["url"]).content, PNG)
self.assertEqual(self.client.get("/api/generations").json()[0]["status"], "success")
self.assertTrue(self.client.get("/api/galleries/user").json())
self.assertEqual(self.client.delete("/api/galleries/user/" + image["filename"]).status_code, 200)
self.assertEqual(self.client.get(image["url"]).status_code, 404)
def test_disabled_or_unknown_model_never_calls_upstream(self):
for model, enabled, status in (("missing-image", True, 404), ("test-image", False, 400)):
providers.set_model_enabled("test:test-image", enabled)
with patch.object(ProviderAdapter, "_request", side_effect=self.upstream) as request:
response = self.client.post("/api/generate", json={**self.payload, "model": model})
self.assertEqual(response.status_code, status)
request.assert_not_called()
def test_disabled_provider_never_calls_upstream(self):
self.client.post("/api/providers", json={**self.provider, "enabled": False})
with patch.object(ProviderAdapter, "_request", side_effect=self.upstream) as request:
self.assertEqual(self.client.post("/api/generate", json=self.payload).status_code, 400)
request.assert_not_called()
def test_unsupported_reference_image_is_rejected(self):
with patch.object(ProviderAdapter, "_request", side_effect=self.upstream) as request:
result = self.client.post("/api/generate", json={**self.payload, "images": ["https://example.invalid/ref.png"]})
self.assertEqual(result.status_code, 400)
request.assert_not_called()
def test_failed_upstream_is_recorded(self):
with patch.object(ProviderAdapter, "_request", side_effect=ProviderError("上游失败")):
self.assertEqual(self.client.post("/api/generate", json=self.payload).status_code, 502)
self.assertEqual(generation.list_generations()[0]["status"], "failed")
def test_empty_and_malformed_image_responses_are_recorded_as_failures(self):
for data in ([], [{"b64_json": "%%%"}]):
with patch.object(ProviderAdapter, "_request", return_value=httpx.Response(200, json={"data": data})):
result = self.client.post("/api/generate", json=self.payload)
self.assertEqual(result.status_code, 502)
self.assertEqual(generation.list_generations()[0]["status"], "failed")
def test_download_failure_is_recorded(self):
response = httpx.Response(200, json={"data": [{"url": "https://example.invalid/image.png"}]})
with patch.object(ProviderAdapter, "_request", return_value=response), patch.object(
ProviderAdapter, "download", side_effect=ProviderError("下载失败")
):
self.assertEqual(self.client.post("/api/generate", json=self.payload).status_code, 502)
self.assertEqual(generation.list_generations()[0]["error"], "下载失败")
def test_gallery_delete_only_changes_its_profile(self):
for profile in ("first", "second"):
folder = config.OUTPUT_DIR / profile
folder.mkdir(exist_ok=True)
(folder / "same.png").write_bytes(PNG)
generation.record_generation(provider_id="test", provider_name="测试", model="test-image",
profile_name=profile, prompt="test", size="1024x1024", ratio="", n=1,
status="success", error="", files=["same.png"], duration_ms=1)
self.client.delete("/api/galleries/first/same.png").raise_for_status()
other = next(g for g in generation.list_generations() if g["profile_name"] == "second")
self.assertEqual(other["files"], ["same.png"])
def test_missing_provider_models_returns_404(self):
result = self.client.post("/api/providers/missing/models", json={"model_ids": ["test-image"]})
self.assertEqual(result.status_code, 404)
def test_model_ids_with_slashes(self):
providers.upsert_models("test", ["vendor/test-image"])
result = self.client.patch("/api/models/test%3Avendor%2Ftest-image", json={"enabled": False})
self.assertEqual(result.status_code, 200)
def test_invalid_generation_inputs(self):
for extra in ({"n": 0}, {"n": 5}, {"prompt": " "}, {"profile_name": "../data"},
{"size": "0x1024"}, {"size": "unknown"}):
self.assertEqual(self.client.post("/api/generate", json={**self.payload, **extra}).status_code, 422)
def test_gallery_rejects_invalid_paths(self):
for profile in ("..", "bad\\path"):
self.assertEqual(self.client.get("/api/galleries/" + profile.replace(".", "%2E")).status_code, 400)
def test_delete_provider_cascades_models(self):
self.client.delete("/api/providers/test").raise_for_status()
self.assertEqual(providers.list_models(), [])
def test_agnes_reference_image_and_size_translation(self):
self.client.post("/api/providers", json={**self.provider, "adapter": "agnes"}).raise_for_status()
providers.upsert_models("test", ["test-image"])
self.assertTrue(providers.get_model("test:test-image")["enabled"])
self.assertTrue(providers.get_model("test:test-image")["supports_image_input"])
with patch.object(ProviderAdapter, "_request", side_effect=self.upstream) as request:
result = self.client.post("/api/generate", json={**self.payload, "size": "2048x1152",
"images": ["data:image/png;base64," + base64.b64encode(PNG).decode()]})
self.assertEqual(result.status_code, 200, result.text)
body = request.call_args.kwargs["json_body"]
self.assertEqual((body["size"], body["ratio"]), ("2K", "16:9"))
self.assertTrue(body["extra_body"]["image"])
self.assertEqual(result.json()["ratio"], "16:9")
def test_tier_is_translated_for_openai(self):
with patch.object(ProviderAdapter, "_request", side_effect=self.upstream) as request:
result = self.client.post("/api/generate", json={**self.payload, "size": "2k"})
self.assertEqual(result.status_code, 200)
self.assertEqual(request.call_args.kwargs["json_body"]["size"], "2048x2048")
def test_discovery_credentials_are_validated(self):
for extra in ({"adapter": "unknown"}, {"base_url": "file:///tmp"}):
response = self.client.post("/api/providers/discover", json={**self.provider, **extra})
self.assertEqual(response.status_code, 422)
def test_agent_service_validates_count(self):
with self.assertRaises(ProviderError):
generation.generate(**self.payload, n=100)
def test_partial_save_failure_rolls_back_images_and_records_failure(self):
with patch("app.services.generation.get_adapter") as get_adapter:
adapter = get_adapter.return_value
adapter.generate.return_value = [ImageResult(data=PNG), ImageResult()]
result = self.client.post("/api/generate", json=self.payload)
self.assertEqual(result.status_code, 502)
record = generation.list_generations()[0]
self.assertEqual((record["status"], record["files"]), ("failed", []))
def test_agent_discovery_only_returns_enabled_models(self):
providers.set_model_enabled("test:test-image", False)
with patch.object(ProviderAdapter, "_request", side_effect=self.upstream):
self.assertEqual(providers.discover("test", require_enabled=True)["matched"], [])
def test_malformed_upstream_payloads_return_controlled_errors(self):
for payload in ([], {"data": {"unexpected": "object"}}):
with patch.object(ProviderAdapter, "_request", return_value=httpx.Response(200, json=payload)):
result = self.client.post("/api/generate", json=self.payload)
self.assertEqual(result.status_code, 502)
self.assertEqual(generation.list_generations()[0]["status"], "failed")
self.assertEqual(self.client.post("/api/providers/test/discover").status_code, 502)
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
"""真实浏览器 + 本地模拟上游的端到端验收,不访问付费服务。"""
import asyncio
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import httpx
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[1]
PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4l8AAAAASUVORK5CYII="
class Upstream(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def reply(self, body, status=200):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
self.reply({"data": [{"id": "vendor/test-image"}, {"id": "text-model"}]})
def do_POST(self):
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if payload["prompt"] == "fail-test":
self.reply({"error": "测试上游拒绝"}, 400)
else:
self.reply({"data": [{"b64_json": PNG} for _ in range(payload.get("n", 1))]})
class BrowserTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.runtime = tempfile.TemporaryDirectory(prefix="ai-image-browser-")
cls.upstream = ThreadingHTTPServer(("127.0.0.1", 0), Upstream)
threading.Thread(target=cls.upstream.serve_forever, daemon=True).start()
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
cls.url = f"http://127.0.0.1:{port}"
cls.env = {**os.environ, "AI_IMAGE_DATA_DIR": str(Path(cls.runtime.name) / "data"),
"AI_IMAGE_DB": str(Path(cls.runtime.name) / "data" / "test.db"),
"AI_IMAGE_OUTPUT_DIR": str(Path(cls.runtime.name) / "output"),
"AI_IMAGE_SECRET_KEY": "browser-test-key", "AI_IMAGE_HOST": "127.0.0.1",
"NO_PROXY": "127.0.0.1,localhost"}
cls.server = subprocess.Popen([sys.executable, "main.py", "--port", str(port)], cwd=ROOT,
env=cls.env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(100):
try:
if httpx.get(cls.url + "/health", timeout=1, trust_env=False).is_success:
break
except httpx.HTTPError:
pass
time.sleep(.1)
else:
cls.server.terminate()
raise RuntimeError("测试服务启动失败")
@classmethod
def tearDownClass(cls):
cls.server.terminate()
cls.server.wait(timeout=10)
cls.upstream.shutdown()
cls.upstream.server_close()
cls.runtime.cleanup()
def test_browser_workflow(self):
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 1000})
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto(self.url + "/providers.html")
page.locator("#fId").fill("browser")
page.locator("#fName").fill("浏览器测试供应商")
page.locator("#fBaseUrl").fill(f"http://127.0.0.1:{self.upstream.server_port}/v1")
page.locator("#fApiKey").fill("fake-key")
page.locator("#saveProviderBtn").click()
expect(page.locator("#providerList")).to_contain_text("浏览器测试供应商")
page.locator('[data-act="discover"]').click()
expect(page.locator("#discoverResult")).to_contain_text("vendor/test-image")
page.locator("#applyDiscoverBtn").click()
expect(page.locator("#toast-host")).to_contain_text("已保存 1 个模型")
page.goto(self.url + "/models.html")
expect(page.locator("#modelRows tr")).to_have_count(1)
page.locator("#disableAllBtn").click()
expect(page.locator("#statEnabled")).to_have_text("0")
page.locator("#enableAllBtn").click()
expect(page.locator("#statEnabled")).to_have_text("1")
page.goto(self.url + "/create.html")
expect(page.locator("#model")).to_have_value("vendor/test-image")
page.locator("#prompt").fill("a test image")
page.locator("#count").select_option("2")
page.locator("#generateBtn").click()
expect(page.locator("#resultStatus")).to_contain_text("生成成功 · 2 张")
expect(page.locator("#resultGrid img")).to_have_count(2)
expect(page.locator("#recentGrid img")).to_have_count(2)
self.assertTrue(page.locator("#resultGrid img").first.evaluate("img => img.complete && img.naturalWidth > 0"))
page.locator("#prompt").fill("fail-test")
page.locator("#generateBtn").click()
expect(page.locator("#resultStatus")).to_have_text("生成失败")
expect(page.locator("#generateBtn")).not_to_have_class("is-loading")
page.goto(self.url + "/records.html")
expect(page.locator("#recRows tr")).to_have_count(2)
page.locator('[data-status="failed"]').click()
expect(page.locator("#recRows tr")).to_have_count(1)
page.locator("#recRows tr").click()
expect(page.locator("#detailModal")).to_have_class("modal-mask open")
page.keyboard.press("Escape")
page.goto(self.url + "/index.html")
expect(page.locator(".folder-card")).to_have_count(1)
page.locator(".folder-card").click()
expect(page.locator("#galleryGrid img")).to_have_count(2)
page.locator("#galleryGrid .gal-item").first.click()
page.on("dialog", lambda dialog: dialog.accept())
page.locator("#lbDelete").click()
expect(page.locator("#galleryGrid img")).to_have_count(1)
expect(page.locator("#statTotal")).to_have_text("1")
page.goto(self.url + "/skills.html")
expect(page.locator("#mdView")).to_contain_text("调用流程")
page.locator("#rawBtn").click()
expect(page.locator("#rawView")).to_be_visible()
for width in (1440, 390):
page.set_viewport_size({"width": width, "height": 900})
for name in ("index", "create", "providers", "models", "records", "skills"):
page.goto(self.url + f"/{name}.html")
expect(page.locator(".top-bar h1")).to_be_visible()
page.wait_for_load_state("networkidle")
overflow = page.evaluate("document.documentElement.scrollWidth > innerWidth")
self.assertFalse(overflow, f"{name} 页面在 {width}px 出现横向溢出")
page.goto(self.url + "/providers.html")
page.locator('[data-act="del"]').click()
page.locator("#confirmDeleteBtn").click()
expect(page.locator("#providerList")).not_to_contain_text("浏览器测试供应商")
self.assertEqual(errors, [])
browser.close()
def test_provider_state_and_default(self):
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page()
page.goto(self.url + "/providers.html")
page.locator("#fId").fill("state")
page.locator("#fName").fill("状态测试")
page.locator("#fBaseUrl").fill(f"http://127.0.0.1:{self.upstream.server_port}/v1")
page.locator("#fApiKey").fill("fake-key")
page.locator("#fDefaultModel").fill("z-image")
page.locator("#saveProviderBtn").click()
expect(page.locator('[data-act="edit"]')).to_be_visible()
response = page.request.post(self.url + "/api/providers/state/models",
data={"model_ids": ["a-image", "z-image"]})
self.assertEqual(response.status, 200)
page.goto(self.url + "/create.html")
expect(page.locator("#model")).to_have_value("z-image")
page.request.patch(self.url + "/api/models/state%3Az-image", data={"enabled": False})
page.reload()
expect(page.locator("#model")).to_have_value("a-image")
page.goto(self.url + "/providers.html")
page.locator('[data-act="edit"]').click()
page.locator("#fEnabled").uncheck()
page.locator("#saveProviderBtn").click()
expect(page.locator("#providerList")).to_contain_text("停用")
page.goto(self.url + "/create.html")
expect(page.locator("#provider")).to_have_value("")
expect(page.locator("#model")).to_have_value("")
page.goto(self.url + "/providers.html")
page.locator('[data-act="edit"]').click()
expect(page.locator("#fEnabled")).not_to_be_checked()
page.locator("#fEnabled").check()
page.locator("#saveProviderBtn").click()
expect(page.locator("#providerList")).to_contain_text("启用")
page.request.delete(self.url + "/api/providers/state")
browser.close()
def test_mcp_http_and_stdio(self):
from fastmcp import Client
from fastmcp.client.transports import StdioTransport
async def check(transport):
async with Client(transport) as client:
names = {tool.name for tool in await client.list_tools()}
self.assertEqual(names, {"list_providers", "discover_models", "generate_image",
"list_generations", "get_skill_doc"})
result = await client.call_tool("get_skill_doc", {})
self.assertFalse(result.is_error)
self.assertIn("ai-image", str(result))
with patch.dict(os.environ, {"NO_PROXY": "127.0.0.1,localhost"}):
asyncio.run(check(self.url + "/mcp"))
asyncio.run(check(StdioTransport(command=sys.executable, args=[str(ROOT / "main.py"), "--stdio"],
env=self.env, cwd=str(ROOT))))
if __name__ == "__main__":
unittest.main()