feat: complete image console and add verified REST and MCP workflows
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user