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")