背景: 9·19 磁盘满事故中业务已瘫但 /health 仍 200(只看模型就绪标志), 网关持续把流量打到本机。深检让 worker 在磁盘不足(min_free_gb, 默认5G)或 ComfyUI/change_hair TCP 不通时主动报 503,网关探测两次失败即摘除。 - 配置: worker_config.json "health" 段,env HEALTH_MIN_FREE_GB/ HEALTH_CHECK_DEPS/HEALTH_DEP_COMFYUI/HEALTH_DEPS_CACHE_SECONDS 可覆盖 - 依赖探测 0.8s 超时并发执行,结果缓存 5s;深检自身异常按就绪返回 - 测试: 深检各分支用例 + 旧用例改为 mock 深检(不依赖本机服务)
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""接口集成测试(FastAPI TestClient):错误码 + 鉴权 + 正常用例结构。"""
|
||
import base64
|
||
import json
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from conftest import fixture
|
||
|
||
import app as app_module
|
||
|
||
# 测试自带固定密码,避免依赖 worker_config.json 的实际值(中间件运行期读模块全局)
|
||
app_module.ACCEPT_PASSWORDS = ["testpass"]
|
||
|
||
URL = "/api/v1/face/measure"
|
||
H = {"X-Internal-Token": "testpass"}
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def client():
|
||
# with 触发 lifespan:加载模型单例(detector + 尽力加载 segmenter)
|
||
with TestClient(app_module.app) as c:
|
||
yield c
|
||
|
||
|
||
def _post(client, fixture_name=None, headers=H, data=None, extra_files=None):
|
||
files = {}
|
||
if fixture_name:
|
||
files["image_file"] = (fixture_name, open(fixture(fixture_name), "rb"), "application/octet-stream")
|
||
if extra_files:
|
||
files.update(extra_files)
|
||
return client.post(URL, headers=headers, files=files or None, data=data)
|
||
|
||
|
||
def test_auth_missing_token_401(client):
|
||
r = _post(client, "frontal.jpg", headers={})
|
||
assert r.status_code == 401
|
||
|
||
|
||
def _patch_health_ok(monkeypatch):
|
||
"""让 /health 深检确定性通过:不依赖本机磁盘水位与 8188/8801 服务存活。"""
|
||
monkeypatch.setattr(app_module, "_HEALTH_CFG", {
|
||
**app_module._HEALTH_CFG, "min_free_gb": 0.0, "check_deps": True})
|
||
|
||
async def _deps_ok():
|
||
return True, {"comfyui": True, "change_hair": True}
|
||
monkeypatch.setattr(app_module, "_check_deps", _deps_ok)
|
||
|
||
|
||
def test_health_no_token_200(client, monkeypatch):
|
||
_patch_health_ok(monkeypatch)
|
||
r = client.get("/health")
|
||
assert r.status_code == 200
|
||
assert r.json()["status"] == "ok"
|
||
|
||
|
||
def test_health_disk_low_503(client, monkeypatch):
|
||
# 阈值调到不可能的 99999G → 必然触发磁盘水位 503
|
||
monkeypatch.setattr(app_module, "_HEALTH_CFG", {
|
||
**app_module._HEALTH_CFG, "min_free_gb": 99999.0, "check_deps": False})
|
||
r = client.get("/health")
|
||
assert r.status_code == 503
|
||
body = r.json()
|
||
assert body["status"] == "degraded"
|
||
assert body["reason"].startswith("disk_free_below")
|
||
|
||
|
||
def test_health_dep_down_503(client, monkeypatch):
|
||
monkeypatch.setattr(app_module, "_HEALTH_CFG", {
|
||
**app_module._HEALTH_CFG, "min_free_gb": 0.0, "check_deps": True})
|
||
|
||
async def _deps_down():
|
||
return False, {"comfyui": False, "change_hair": True}
|
||
monkeypatch.setattr(app_module, "_check_deps", _deps_down)
|
||
r = client.get("/health")
|
||
assert r.status_code == 503
|
||
body = r.json()
|
||
assert body["status"] == "degraded"
|
||
assert "comfyui" in body["reason"]
|
||
|
||
|
||
def test_health_ok_carries_detail(client, monkeypatch):
|
||
_patch_health_ok(monkeypatch)
|
||
r = client.get("/health")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "disk_free_gb" in body
|
||
assert body["deps"] == {"comfyui": True, "change_hair": True}
|
||
|
||
|
||
def test_tcp_probe_real_socket():
|
||
# 用进程内真实 socket 验证探测本身(曾因 open_connection 返回元组误判全部不可达)
|
||
import asyncio
|
||
|
||
async def scenario():
|
||
server = await asyncio.start_server(lambda r, w: None, host="127.0.0.1", port=0)
|
||
port = server.sockets[0].getsockname()[1]
|
||
up = await app_module._tcp_probe("127.0.0.1", port)
|
||
server.close()
|
||
await server.wait_closed()
|
||
# 端口 1 无监听,本机 connect 立即被拒
|
||
down = await app_module._tcp_probe("127.0.0.1", 1)
|
||
return up, down
|
||
|
||
up, down = asyncio.run(scenario())
|
||
assert up is True
|
||
assert down is False
|
||
|
||
|
||
def test_param_none_provided_1007(client):
|
||
r = client.post(URL, headers=H)
|
||
assert r.json()["code"] == 1007
|
||
|
||
|
||
def test_param_multiple_provided_1007(client):
|
||
r = _post(client, "frontal.jpg", data={"image_url": "http://example.com/x.jpg"})
|
||
assert r.json()["code"] == 1007
|
||
|
||
|
||
def test_no_face_1001(client):
|
||
r = _post(client, "landscape.jpg")
|
||
assert r.json()["code"] == 1001
|
||
|
||
|
||
def test_corrupt_1008(client):
|
||
r = _post(client, "corrupt.bin")
|
||
assert r.json()["code"] == 1008
|
||
|
||
|
||
GROW = "/api/v1/hair/grow"
|
||
|
||
|
||
def test_grow_missing_gender_1004(client):
|
||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||
r = client.post(GROW, headers=H, files=files)
|
||
assert r.json()["code"] == 1004
|
||
|
||
|
||
# mock ComfyUI 输出:一张合法 PNG(worker 会把它重编码成 JPG)
|
||
import cv2 as _cv2
|
||
import numpy as _np
|
||
_PNG_1x1 = _cv2.imencode(".png", _np.full((8, 8, 3), 200, _np.uint8))[1].tobytes()
|
||
|
||
|
||
def test_grow_female_returns_5(client, monkeypatch):
|
||
# mock ComfyUI:不依赖 8182、不跑 Flux,只验证管线接线 + grown 字段
|
||
import hairline.comfyui as comfy
|
||
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
||
|
||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||
# hair_style 必填(cb1989c 起):指定全部 5 种发型以验证完整管线
|
||
r = client.post(GROW, headers=H, files=files, data={"gender": "female", "hair_style": "1,2,3,4,5"})
|
||
body = r.json()
|
||
assert body["code"] == 0, body
|
||
results = body["data"]["results"]
|
||
assert [x["hairline_type"] for x in results] == ["ellipse", "flower", "heart", "straight", "wave"]
|
||
assert [x["order"] for x in results] == [1, 2, 3, 4, 5]
|
||
assert base64.b64decode(results[0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n" # 透明 PNG(发际线曲线)
|
||
assert base64.b64decode(results[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG(生发图)
|
||
assert "image_url" not in results[0]
|
||
|
||
|
||
GROWB = "/api/v1/hair/grow-b"
|
||
|
||
|
||
def test_growb_missing_marked_1007(client):
|
||
r = client.post(GROWB, headers=H) # 一张图都没传
|
||
assert r.json()["code"] == 1007
|
||
|
||
|
||
def test_growb_no_line_1001(client):
|
||
files = {"marked_image_file": ("m.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||
r = client.post(GROWB, headers=H, files=files) # 无划线 → 拒识
|
||
assert r.json()["code"] == 1001
|
||
|
||
|
||
def test_growb_success(client, monkeypatch):
|
||
import hairline.comfyui as comfy
|
||
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
||
files = {"marked_image_file": ("m.jpg", open(fixture("marked_hairline.jpg"), "rb"), "application/octet-stream")}
|
||
body = client.post(GROWB, headers=H, files=files).json() # 只传划线图一张
|
||
assert body["code"] == 0, body
|
||
d = body["data"]
|
||
assert d["hairline_type"] == "custom"
|
||
assert base64.b64decode(d["hair_growth_image_base64"])[:3] == b"\xff\xd8\xff" # JPEG
|
||
assert "best_hairline_image_base64" not in d # 已去掉该字段
|
||
assert "best_hairline_image_url" not in d
|
||
|
||
|
||
HLGEN = "/api/v1/hairline/generate"
|
||
|
||
|
||
def test_hairline_gen_missing_gender_1004(client):
|
||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||
assert client.post(HLGEN, headers=H, files=files).json()["code"] == 1004
|
||
|
||
|
||
def test_hairline_gen_missing_hairstyle_1007(client):
|
||
# hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007
|
||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||
assert client.post(HLGEN, headers=H, files=files, data={"gender": "female"}).json()["code"] == 1007
|
||
|
||
|
||
def test_hairline_gen_female(client, monkeypatch):
|
||
# mock ComfyUI:不依赖 8182,只验证三档叠图 + 生发字段接线
|
||
import hairline.comfyui as comfy
|
||
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
||
|
||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||
body = client.post(HLGEN, headers=H, files=files, data={"gender": "female", "hair_style": "1,3"}).json()
|
||
assert body["code"] == 0, body
|
||
d = body["data"]
|
||
imgs = d["hairline_images"]
|
||
# 只返回选中发型,order = 发型序号
|
||
assert [x["order"] for x in imgs] == [1, 3]
|
||
assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"]
|
||
# 三档叠图为透明 PNG(发际线曲线,含 alpha),生发图为 JPEG(完整人像)
|
||
for k in ("image_middle_base64", "image_high_base64", "image_low_base64"):
|
||
assert base64.b64decode(imgs[0][k])[:8] == b"\x89PNG\r\n\x1a\n", k
|
||
assert base64.b64decode(imgs[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff"
|
||
assert "image_middle_url" not in imgs[0]
|
||
c = d["best_hairline_center_point"]
|
||
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内
|
||
# face_measure:复用接口1测量数值(容错,失败为 null;本用例正常 → 必须有完整结构)
|
||
fm = d["face_measure"]
|
||
assert fm is not None, "face_measure 不应为 null(正常正面照)"
|
||
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
|
||
"landmarks", "hairline_source", "head_pose"]).issubset(fm.keys())
|
||
# 不应含标注图字段(接口5 只要数值,不要画线图)
|
||
assert "annotated_image_base64" not in fm
|
||
assert "annotated_image_url" not in fm
|
||
# 子结构
|
||
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
|
||
"lower_court_cm", "ratios"]).issubset(fm["four_courts"].keys())
|
||
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
|
||
"ratios"]).issubset(fm["seven_eyes"].keys())
|
||
# 七眼 eye1~eye7 键必须存在(eye1/eye7 在耳朵不可见时可为 null)
|
||
assert set([f"eye{i}" for i in range(1, 8)]).issubset(fm["seven_eyes"].keys())
|
||
assert set(["hair_top", "hairline", "brow_center",
|
||
"nose_bottom", "chin_tip"]).issubset(fm["landmarks"].keys())
|
||
assert fm["hairline_source"] in ("segmentation", "estimated", "corrected")
|
||
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
|
||
|
||
|
||
# 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker;
|
||
# 其测试随实现一起在网关侧做,worker 这边不再覆盖。
|
||
|
||
|
||
def test_success_structure(client):
|
||
r = _post(client, "frontal.jpg")
|
||
body = r.json()
|
||
assert body["code"] == 0, body
|
||
data = body["data"]
|
||
# 业务字段对齐文档
|
||
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
|
||
"landmarks", "hairline_source", "head_pose",
|
||
"annotated_image_base64"]).issubset(data.keys())
|
||
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
|
||
"lower_court_cm", "ratios"]).issubset(data["four_courts"].keys())
|
||
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
|
||
"ratios"]).issubset(data["seven_eyes"].keys())
|
||
# 七眼:从左到右 eye1~eye7(eye1/eye7 在耳朵不可见时可为 null,但键必须存在)
|
||
assert set([f"eye{i}" for i in range(1, 8)]).issubset(data["seven_eyes"].keys())
|
||
# eye3/eye5 为左右眼宽、eye4 为两眼间距,与 eye_width_cm/inter_eye_distance_cm 语义一致
|
||
assert data["seven_eyes"]["eye3"] is not None
|
||
assert data["seven_eyes"]["eye4"] is not None
|
||
assert data["seven_eyes"]["eye5"] is not None
|
||
assert data["hairline_source"] in ("segmentation", "estimated", "corrected")
|
||
# base64 解码为合法 PNG(非 URL)
|
||
assert "annotated_image_url" not in data
|
||
png = base64.b64decode(data["annotated_image_base64"])
|
||
assert png[:8] == b"\x89PNG\r\n\x1a\n"
|