Align task API and add FunCaptcha support

This commit is contained in:
Hua
2026-03-12 19:32:59 +08:00
parent ef9518deeb
commit bc6776979e
33 changed files with 3446 additions and 672 deletions

423
tests/test_server.py Normal file
View File

@@ -0,0 +1,423 @@
import asyncio
import base64
import time
from pathlib import Path
from types import SimpleNamespace
from urllib.error import URLError
from urllib.parse import urlencode
import pytest
pytest.importorskip("fastapi")
from fastapi.responses import JSONResponse
from config import SERVER_CONFIG
import server as server_module
from server import create_app
class _FakePipeline:
def solve(self, image, captcha_type=None):
return {
"type": captcha_type or "normal",
"result": "A3B8",
"raw": "A3B8",
"time_ms": 1.23,
}
class _FakeFunPipeline:
def solve(self, image):
return {
"type": "funcaptcha",
"question": "4_3d_rollball_animals",
"objects": [2],
"result": "2",
"raw": "2",
"time_ms": 2.34,
}
def _create_test_app(funcaptcha_factories=None):
return create_app(
pipeline_factory=_FakePipeline,
funcaptcha_factories=funcaptcha_factories,
)
def _get_route(app, path: str):
for route in app.routes:
if getattr(route, "path", None) == path:
return route.endpoint
raise AssertionError(f"route not found: {path}")
def _fake_request(host: str = "127.0.0.1"):
return SimpleNamespace(client=SimpleNamespace(host=host))
@pytest.fixture(autouse=True)
def _reset_server_config(monkeypatch, tmp_path):
monkeypatch.setitem(SERVER_CONFIG, "client_key", None)
monkeypatch.setitem(SERVER_CONFIG, "tasks_dir", str(tmp_path / "server_tasks"))
monkeypatch.setitem(SERVER_CONFIG, "task_cost", 0.0)
monkeypatch.setitem(SERVER_CONFIG, "callback_max_retries", 2)
monkeypatch.setitem(SERVER_CONFIG, "callback_retry_delay_seconds", 1.0)
monkeypatch.setitem(SERVER_CONFIG, "callback_retry_backoff", 2.0)
monkeypatch.setitem(SERVER_CONFIG, "callback_signing_secret", None)
def test_solve_base64_returns_sync_payload():
app = _create_test_app()
solve = _get_route(app, "/api/v1/solve")
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
response = asyncio.run(
solve(SimpleNamespace(image=encoded, type="math"))
)
assert response == {
"type": "math",
"result": "A3B8",
"raw": "A3B8",
"time_ms": 1.23,
}
def test_create_task_and_get_task_result():
app = _create_test_app()
create_task = _get_route(app, "/createTask")
get_task_result = _get_route(app, "/getTaskResult")
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
create_response = asyncio.run(
create_task(
SimpleNamespace(
clientKey="local",
task=SimpleNamespace(
type="ImageToTextTaskM1",
body=encoded,
image=None,
captchaType="normal",
),
),
_fake_request("10.0.0.8"),
)
)
assert create_response["errorId"] == 0
assert create_response["status"] == "processing"
assert isinstance(create_response["createTime"], int)
assert isinstance(create_response["expiresAt"], int)
task_id = create_response["taskId"]
result_response = None
for _ in range(20):
result_response = asyncio.run(
get_task_result(SimpleNamespace(clientKey="local", taskId=task_id))
)
if result_response.get("status") == "ready":
break
assert result_response is not None
assert result_response["errorId"] == 0
assert result_response["status"] == "ready"
assert result_response["solution"] == {
"text": "A3B8",
"answer": "A3B8",
"raw": "A3B8",
"captchaType": "normal",
"timeMs": 1.23,
}
assert result_response["cost"] == "0.00000"
assert result_response["ip"] == "10.0.0.8"
assert result_response["solveCount"] == 1
assert result_response["task"] == {
"type": "ImageToTextTaskM1",
"captchaType": "normal",
}
assert result_response["callback"] == {
"configured": False,
"url": None,
"attempts": 0,
"delivered": False,
"deliveredAt": None,
"lastError": None,
}
assert isinstance(result_response["expiresAt"], int)
def test_get_task_result_returns_not_found_for_unknown_task():
app = _create_test_app()
get_task_result = _get_route(app, "/getTaskResult")
response = asyncio.run(
get_task_result(SimpleNamespace(clientKey="local", taskId="missing-task"))
)
assert response["errorCode"] == "ERROR_TASK_NOT_FOUND"
def test_solve_returns_json_error_for_invalid_base64():
app = _create_test_app()
solve = _get_route(app, "/solve")
response = asyncio.run(
solve(SimpleNamespace(image="not_base64!", type="normal"))
)
assert isinstance(response, JSONResponse)
assert response.status_code == 400
def test_health_alias_reports_client_key_flag(monkeypatch):
app = _create_test_app()
health = _get_route(app, "/api/v1/health")
monkeypatch.setitem(SERVER_CONFIG, "client_key", "secret")
response = health()
assert response["status"] == "ok"
assert response["client_key_required"] is True
assert "ImageToTextTask" in response["supported_task_types"]
def test_client_key_is_required_for_task_api(monkeypatch):
app = _create_test_app()
create_task = _get_route(app, "/api/v1/createTask")
get_balance = _get_route(app, "/api/v1/getBalance")
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
monkeypatch.setitem(SERVER_CONFIG, "client_key", "secret")
create_response = asyncio.run(
create_task(
SimpleNamespace(
clientKey="wrong-key",
task=SimpleNamespace(
type="ImageToTextTask",
body=encoded,
image=None,
captchaType="normal",
),
),
_fake_request(),
)
)
balance_response = asyncio.run(
get_balance(SimpleNamespace(clientKey="wrong-key"))
)
assert create_response["errorCode"] == "ERROR_KEY_DOES_NOT_EXIST"
assert balance_response["errorCode"] == "ERROR_KEY_DOES_NOT_EXIST"
def test_create_task_triggers_callback(monkeypatch):
app = _create_test_app()
create_task = _get_route(app, "/createTask")
callbacks = []
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
def _fake_post_callback(callback_url, payload):
callbacks.append((callback_url, payload))
monkeypatch.setattr(server_module, "_post_callback", _fake_post_callback)
response = asyncio.run(
create_task(
SimpleNamespace(
clientKey="local",
callbackUrl="https://example.com/callback",
task=SimpleNamespace(
type="ImageToTextTask",
body=encoded,
image=None,
captchaType="normal",
),
),
_fake_request("10.0.0.9"),
)
)
assert response["errorId"] == 0
for _ in range(20):
if callbacks:
break
time.sleep(0.01)
assert callbacks == [
(
"https://example.com/callback",
{
"id": response["taskId"],
"taskId": response["taskId"],
"status": "ready",
"errorId": "0",
"code": "A3B8",
"text": "A3B8",
"answer": "A3B8",
"raw": "A3B8",
"captchaType": "normal",
"timeMs": "1.23",
"cost": "0.00000",
},
)
]
def test_create_task_routes_fun_captcha_question():
app = _create_test_app(
funcaptcha_factories={"4_3d_rollball_animals": _FakeFunPipeline}
)
create_task = _get_route(app, "/createTask")
get_task_result = _get_route(app, "/getTaskResult")
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
create_response = asyncio.run(
create_task(
SimpleNamespace(
clientKey="local",
task=SimpleNamespace(
type="FunCaptcha",
body=encoded,
image=None,
captchaType=None,
question="4_3d_rollball_animals",
),
),
_fake_request("10.0.0.7"),
)
)
task_id = create_response["taskId"]
result_response = None
for _ in range(20):
result_response = asyncio.run(
get_task_result(SimpleNamespace(clientKey="local", taskId=task_id))
)
if result_response.get("status") == "ready":
break
assert result_response["errorId"] == 0
assert result_response["status"] == "ready"
assert result_response["solution"] == {
"objects": [2],
"answer": 2,
"raw": "2",
"timeMs": 2.34,
"question": "4_3d_rollball_animals",
"text": "2",
}
assert result_response["task"] == {
"type": "FunCaptcha",
"captchaType": None,
"question": "4_3d_rollball_animals",
}
def test_create_task_retries_callback(monkeypatch):
app = _create_test_app()
create_task = _get_route(app, "/createTask")
attempts = []
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
def _flaky_post_callback(callback_url, payload):
attempts.append((callback_url, payload["taskId"]))
if len(attempts) < 3:
raise URLError("temporary failure")
monkeypatch.setattr(server_module, "_post_callback", _flaky_post_callback)
monkeypatch.setitem(SERVER_CONFIG, "callback_retry_delay_seconds", 0.0)
monkeypatch.setitem(SERVER_CONFIG, "callback_retry_backoff", 1.0)
response = asyncio.run(
create_task(
SimpleNamespace(
clientKey="local",
callbackUrl="https://example.com/callback",
task=SimpleNamespace(
type="ImageToTextTask",
body=encoded,
image=None,
captchaType="normal",
),
),
_fake_request(),
)
)
for _ in range(20):
if len(attempts) >= 3:
break
time.sleep(0.01)
assert response["errorId"] == 0
assert response["status"] == "processing"
assert attempts == [
("https://example.com/callback", response["taskId"]),
("https://example.com/callback", response["taskId"]),
("https://example.com/callback", response["taskId"]),
]
def test_tasks_are_restored_from_disk():
app = _create_test_app()
create_task = _get_route(app, "/createTask")
get_task_result = _get_route(app, "/getTaskResult")
encoded = base64.b64encode(b"fake-image-bytes").decode("ascii")
create_response = asyncio.run(
create_task(
SimpleNamespace(
clientKey="local",
task=SimpleNamespace(
type="ImageToTextTask",
body=encoded,
image=None,
captchaType="normal",
),
),
_fake_request(),
)
)
task_id = create_response["taskId"]
for _ in range(20):
result = asyncio.run(
get_task_result(SimpleNamespace(clientKey="local", taskId=task_id))
)
if result.get("status") == "ready":
break
time.sleep(0.01)
task_file = Path(SERVER_CONFIG["tasks_dir"]) / f"{task_id}.json"
assert task_file.exists()
reloaded_app = _create_test_app()
reloaded_get_task_result = _get_route(reloaded_app, "/getTaskResult")
reloaded_result = asyncio.run(
reloaded_get_task_result(SimpleNamespace(clientKey="local", taskId=task_id))
)
assert reloaded_result["status"] == "ready"
assert reloaded_result["solution"]["answer"] == "A3B8"
def test_callback_request_includes_signature_headers(monkeypatch):
monkeypatch.setitem(SERVER_CONFIG, "callback_signing_secret", "shared-secret")
request = server_module._build_callback_request(
"https://example.com/callback",
{"taskId": "abc", "status": "ready"},
)
body = urlencode({"taskId": "abc", "status": "ready"}).encode("utf-8")
headers = {key.lower(): value for key, value in request.header_items()}
timestamp = headers["x-captchabreaker-timestamp"]
signature = headers["x-captchabreaker-signature"]
assert headers["content-type"] == "application/x-www-form-urlencoded"
assert headers["x-captchabreaker-signature-alg"] == "hmac-sha256"
assert signature == server_module._sign_callback_payload(body, timestamp, "shared-secret")