Align task API and add FunCaptcha support
This commit is contained in:
423
tests/test_server.py
Normal file
423
tests/test_server.py
Normal 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")
|
||||
Reference in New Issue
Block a user