"""gateway.reqlog 单测:multipart 入参解析(标量 + 图片存盘转 URL)+ 出参摘要。 不依赖网络 / GPU,纯函数级。 """ import base64 import json from gateway.reqlog import extract_form_params, summarize_for_log # 1x1 透明 PNG(合法 magic byte) _PNG = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01" b"\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) def _multipart(fields, files, boundary=b"----testbnd"): """手搓 multipart/form-data body。fields: {name:str}; files: {name:(fname,ctype,bytes)}.""" parts = [] for name, val in fields.items(): parts.append(b"--" + boundary + b"\r\n") parts.append(b'Content-Disposition: form-data; name="' + name.encode() + b'"\r\n\r\n') parts.append(val.encode("utf-8") + b"\r\n") for name, (fname, ctype, data) in files.items(): parts.append(b"--" + boundary + b"\r\n") parts.append(b'Content-Disposition: form-data; name="' + name.encode() + b'"; filename="' + fname.encode() + b'"\r\n') parts.append(b"Content-Type: " + ctype.encode() + b"\r\n\r\n") parts.append(data + b"\r\n") parts.append(b"--" + boundary + b"--\r\n") return b"".join(parts) def test_extract_scalars_and_images(tmp_path): b64 = "data:image/png;base64," + base64.b64encode(_PNG).decode() body = _multipart( fields={"gender": "female", "hair_style": "1,3", "use_mask": "True", "prompt": "填充遮罩区域的头发", "image_base64": b64}, files={"image_file": ("x.png", "image/png", _PNG)}, ) ct = "multipart/form-data; boundary=----testbnd" res = extract_form_params(ct, body, str(tmp_path), "https://test.local") # 标量原样 assert res["gender"] == "female" assert res["hair_style"] == "1,3" assert res["use_mask"] == "True" # 图片字段是元信息 dict(不是 base64 文本) assert res["image_file"]["_image"] is True assert res["image_file"]["source"] == "file" assert res["image_file"]["filename"] == "x.png" assert res["image_file"]["url"] is not None assert res["image_base64"]["_image"] is True assert res["image_base64"]["source"] == "base64" assert res["image_base64"]["url"] is not None # 图片已落盘 saved = [p.name for p in tmp_path.iterdir()] assert any(n.startswith("in_") and n.endswith(".png") for n in saved) # URL 指向正确路径 assert res["image_file"]["url"].startswith("https://test.local/static/annotations/in_") # 关键:日志里绝不包含 base64 文本 blob = json.dumps(res, ensure_ascii=False) assert b64.split(",", 1)[1][:40] not in blob def test_extract_non_multipart(tmp_path): res = extract_form_params("application/json", b'{"a":1}', str(tmp_path), "https://t.local") assert res == {"_content_type": "application/json", "_body_bytes": 7} def test_extract_empty_content_type(tmp_path): res = extract_form_params("", b"hello", str(tmp_path), "https://t.local") assert res["_body_bytes"] == 5 def test_summarize_truncates_long_values(): big = { "landmarks": [[i, i] for i in range(500)], "name": "x" * 1000, "deep": {"a": {"b": {"c": {"d": {"e": {"f": {"g": 1}}}}}}}, } sm = summarize_for_log(big) # 长数组被截断 + 标注剩余数 assert isinstance(sm["landmarks"], list) assert sm["landmarks"][-1].startswith("…(+") assert len(sm["landmarks"]) == 6 # 5 项 + 1 个标注 # 长字符串被截断 assert "…(+" in sm["name"] # 总体积受控 assert len(json.dumps(sm, ensure_ascii=False).encode()) <= 8192 def test_summarize_keeps_small_intact(): obj = {"code": 0, "data": {"hairline_images": [{"order": 1, "image_middle_url": "https://x/y.png"}]}} assert summarize_for_log(obj) == obj def test_summarize_byte_cap_stub(): # 构造大量小字段触发字节上限 obj = {f"k{i}": {f"j{j}": "abcdefghij" for j in range(50)} for i in range(50)} sm = summarize_for_log(obj, max_bytes=2048) assert isinstance(sm, dict) and sm.get("_truncated") is True assert "size_bytes" in sm