|
4 | 4 | import asyncio |
5 | 5 | import json |
6 | 6 | import os |
| 7 | +import pathlib |
7 | 8 | import shutil |
8 | 9 | import time |
9 | 10 | import typing |
10 | 11 | import uuid |
11 | 12 |
|
12 | 13 | import mock |
13 | 14 | import octobot.constants as octobot_app_constants |
| 15 | +import octobot_commons.configuration as configuration_module |
14 | 16 | import octobot_commons.constants as common_constants |
| 17 | +import octobot_commons.dsl_interpreter as dsl_interpreter |
15 | 18 | import octobot_commons.process_util as process_util |
16 | 19 | import octobot_node.constants as octobot_node_constants |
17 | 20 | import pytest |
@@ -43,7 +46,7 @@ async def test_run_octobot_process_lifecycle_grid_trading( |
43 | 46 | run_dsl = ( |
44 | 47 | "run_octobot_process(" |
45 | 48 | f"{user_folder!r}, {repr(octobot_process_functional_shared.GRID_BINANCEUS_PROFILE_DATA)}, " |
46 | | - "waiting_time=2.0, ping_timeout=30.0)" |
| 49 | + f"waiting_time={octobot_process_functional_shared.WAITING_TIME_RUN_OCTOBOT_PROCESS_SEC}, ping_timeout=30.0)" |
47 | 50 | ) |
48 | 51 | run_action = { |
49 | 52 | "id": octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT, |
@@ -298,3 +301,210 @@ async def test_run_octobot_process_lifecycle_grid_trading( |
298 | 301 | shutil.rmtree(user_root_guess, ignore_errors=True) |
299 | 302 | if os.path.isdir(log_folder_guess): |
300 | 303 | shutil.rmtree(log_folder_guess, ignore_errors=True) |
| 304 | + |
| 305 | + |
| 306 | +async def test_run_octobot_process_lifecycle_default_config_no_profile_data( |
| 307 | + init_action: dict, |
| 308 | + monkeypatch: pytest.MonkeyPatch, |
| 309 | +): |
| 310 | + if not os.path.isfile(os.path.join(os.getcwd(), "start.py")): |
| 311 | + pytest.skip("start.py missing: run pytest with cwd set to the OctoBot project root") |
| 312 | + |
| 313 | + non_trading_profile_json = os.path.join( |
| 314 | + os.getcwd(), |
| 315 | + common_constants.USER_FOLDER, |
| 316 | + common_constants.PROFILES_FOLDER, |
| 317 | + "non-trading", |
| 318 | + common_constants.PROFILE_CONFIG_FILE, |
| 319 | + ) |
| 320 | + if not os.path.isfile(non_trading_profile_json): |
| 321 | + pytest.skip("non-trading profile missing under OctoBot user/profiles") |
| 322 | + |
| 323 | + monkeypatch.setenv(octobot_app_constants.ENV_PROCESS_BOT_STATE_DUMP_INTERVAL_SECONDS, "5") |
| 324 | + |
| 325 | + user_folder = f"functionnal_tests/octlife_default_{uuid.uuid4().hex[:12]}" |
| 326 | + exchange_auth = [ |
| 327 | + { |
| 328 | + "internal_name": octobot_process_functional_shared.EXCHANGE_BINANCEUS, |
| 329 | + "api_key": "functional-default-config-key", |
| 330 | + "api_secret": "functional-default-config-secret", |
| 331 | + "sandboxed": True, |
| 332 | + "exchange_type": common_constants.CONFIG_EXCHANGE_SPOT, |
| 333 | + } |
| 334 | + ] |
| 335 | + run_dsl = ( |
| 336 | + f"run_octobot_process({user_folder!r}, " |
| 337 | + f"exchange_auth_data={dsl_interpreter.format_parameter_value(exchange_auth)}, " |
| 338 | + f"waiting_time={octobot_process_functional_shared.WAITING_TIME_RUN_OCTOBOT_PROCESS_SEC}, ping_timeout=30.0)" |
| 339 | + ) |
| 340 | + run_action = { |
| 341 | + "id": octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT, |
| 342 | + "dsl_script": run_dsl, |
| 343 | + "dependencies": [{"action_id": octobot_process_functional_shared.ACTION_ID_INIT}], |
| 344 | + } |
| 345 | + stop_automation_action = { |
| 346 | + "id": octobot_process_functional_shared.ACTION_ID_STOP_AUTOMATION, |
| 347 | + "dsl_script": "stop_automation()", |
| 348 | + "dependencies": [{"action_id": octobot_process_functional_shared.ACTION_ID_INIT}], |
| 349 | + } |
| 350 | + |
| 351 | + popen_calls = {"count": 0} |
| 352 | + tracked_spawn_managed = ( |
| 353 | + octobot_process_functional_shared._make_tracked_spawn_managed_with_forward_terminal_output( |
| 354 | + process_util.spawn_managed_subprocess, |
| 355 | + popen_calls, |
| 356 | + ) |
| 357 | + ) |
| 358 | + |
| 359 | + user_root_guess = os.path.normpath( |
| 360 | + os.path.join( |
| 361 | + os.getcwd(), |
| 362 | + *common_constants.USER_AUTOMATIONS_FOLDER.split("/"), |
| 363 | + *user_folder.replace("\\", "/").split("/"), |
| 364 | + ) |
| 365 | + ) |
| 366 | + log_folder_guess = os.path.normpath( |
| 367 | + os.path.join( |
| 368 | + os.getcwd(), |
| 369 | + *octobot_node_constants.AUTOMATION_LOGS_FOLDER.split("/"), |
| 370 | + *[segment for segment in user_folder.replace("\\", "/").split("/") if segment], |
| 371 | + ) |
| 372 | + ) |
| 373 | + |
| 374 | + try: |
| 375 | + with ( |
| 376 | + functionnal_tests.mocked_community_authentication(), |
| 377 | + functionnal_tests.mocked_community_repository(), |
| 378 | + mock.patch.object( |
| 379 | + process_util, |
| 380 | + "spawn_managed_subprocess", |
| 381 | + side_effect=tracked_spawn_managed, |
| 382 | + ), |
| 383 | + ): |
| 384 | + state = functionnal_tests.automation_state_dict( |
| 385 | + functionnal_tests.resolved_actions([init_action]) |
| 386 | + ) |
| 387 | + async with octobot_flow.jobs.AutomationJob(state, [], [], {}) as init_job: |
| 388 | + await init_job.run() |
| 389 | + state = init_job.dump() |
| 390 | + |
| 391 | + async with octobot_flow.jobs.AutomationJob(state, [], [], {}) as job: |
| 392 | + job.automation_state.upsert_automation_actions( |
| 393 | + functionnal_tests.resolved_actions([run_action]) |
| 394 | + ) |
| 395 | + state = job.dump() |
| 396 | + |
| 397 | + deadline = time.monotonic() + octobot_process_functional_shared.GLOBAL_START_TIMEOUT_SEC |
| 398 | + inner: typing.Optional[dict] = None |
| 399 | + async with octobot_flow.jobs.AutomationJob(state, [], [], {}) as first_poll: |
| 400 | + await first_poll.run() |
| 401 | + octobot_process_functional_shared._assert_run_octobot_process_recall_scheduled_to_in_dump( |
| 402 | + first_poll.dump() |
| 403 | + ) |
| 404 | + first_run = octobot_process_functional_shared._get_action_by_id( |
| 405 | + first_poll, octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT |
| 406 | + ) |
| 407 | + assert first_run is not None |
| 408 | + inner = octobot_process_functional_shared._recall_inner_from_dsl_action(first_run) |
| 409 | + state = first_poll.dump() |
| 410 | + if not (inner and inner.get("init_state_ok") is True): |
| 411 | + while time.monotonic() < deadline: |
| 412 | + await asyncio.sleep(octobot_process_functional_shared.SLEEP_BETWEEN_JOB_POLLS_SEC) |
| 413 | + async with octobot_flow.jobs.AutomationJob(state, [], [], {}) as poll_job: |
| 414 | + await poll_job.run() |
| 415 | + octobot_process_functional_shared._assert_run_octobot_process_recall_scheduled_to_in_dump( |
| 416 | + poll_job.dump() |
| 417 | + ) |
| 418 | + run_details = octobot_process_functional_shared._get_action_by_id( |
| 419 | + poll_job, octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT |
| 420 | + ) |
| 421 | + assert run_details is not None |
| 422 | + inner = octobot_process_functional_shared._recall_inner_from_dsl_action(run_details) |
| 423 | + if inner and inner.get("init_state_ok") is True: |
| 424 | + state = poll_job.dump() |
| 425 | + break |
| 426 | + state = poll_job.dump() |
| 427 | + else: |
| 428 | + pytest.fail( |
| 429 | + f"OctoBot did not become ready (init_state_ok) within " |
| 430 | + f"{octobot_process_functional_shared.GLOBAL_START_TIMEOUT_SEC}s" |
| 431 | + ) |
| 432 | + |
| 433 | + assert inner is not None |
| 434 | + assert inner.get("pid"), "expected child pid in ensure state" |
| 435 | + assert popen_calls["count"] >= 1 |
| 436 | + child_pid = int(inner["pid"]) |
| 437 | + assert process_util.pid_is_running(child_pid) |
| 438 | + |
| 439 | + user_root = pathlib.Path(inner["user_root"]) |
| 440 | + assert inner.get("profile_id") == "non-trading" |
| 441 | + root_cfg = json.loads((user_root / common_constants.CONFIG_FILE).read_text(encoding="utf-8")) |
| 442 | + exchange_cfg = root_cfg[common_constants.CONFIG_EXCHANGES][ |
| 443 | + octobot_process_functional_shared.EXCHANGE_BINANCEUS |
| 444 | + ] |
| 445 | + exchange_auth_entry = exchange_auth[0] |
| 446 | + stored_api_key = exchange_cfg[common_constants.CONFIG_EXCHANGE_KEY] |
| 447 | + stored_api_secret = exchange_cfg[common_constants.CONFIG_EXCHANGE_SECRET] |
| 448 | + assert configuration_module.decrypt(stored_api_key) == exchange_auth_entry["api_key"] |
| 449 | + assert configuration_module.decrypt(stored_api_secret) == exchange_auth_entry["api_secret"] |
| 450 | + profile_json_path = ( |
| 451 | + user_root |
| 452 | + / common_constants.PROFILES_FOLDER |
| 453 | + / "non-trading" |
| 454 | + / common_constants.PROFILE_CONFIG_FILE |
| 455 | + ) |
| 456 | + assert profile_json_path.is_file() |
| 457 | + |
| 458 | + state_path = os.path.normpath( |
| 459 | + os.path.join( |
| 460 | + inner["user_root"], |
| 461 | + octobot_app_constants.PROCESS_BOT_STATE_FILE_NAME, |
| 462 | + ) |
| 463 | + ) |
| 464 | + assert os.path.isfile(state_path) |
| 465 | + with open(state_path, encoding="utf-8") as process_state_file: |
| 466 | + file_metadata_payload = json.load(process_state_file) |
| 467 | + process_metadata = process_bot_state_import.Metadata.from_dict( |
| 468 | + file_metadata_payload["metadata"] |
| 469 | + ) |
| 470 | + assert isinstance(process_metadata, process_bot_state_import.Metadata) |
| 471 | + assert isinstance(process_metadata.updated_at, (int, float)) |
| 472 | + assert isinstance(process_metadata.next_updated_at, (int, float)) |
| 473 | + assert process_metadata.updated_at <= time.time() |
| 474 | + assert process_metadata.next_updated_at >= process_metadata.updated_at |
| 475 | + assert abs( |
| 476 | + (process_metadata.next_updated_at - process_metadata.updated_at) |
| 477 | + - octobot_process_functional_shared.EXPECTED_PROCESS_BOT_DUMP_INTERVAL_SEC |
| 478 | + ) < 1.0 |
| 479 | + |
| 480 | + priority_actions = functionnal_tests.resolved_actions([stop_automation_action]) |
| 481 | + async with octobot_flow.jobs.AutomationJob(state, priority_actions, [], {}) as stop_phase: |
| 482 | + await stop_phase.run() |
| 483 | + octobot_process_functional_shared._assert_run_octobot_process_recall_scheduled_to_in_dump( |
| 484 | + stop_phase.dump(), |
| 485 | + assert_delay_matches_waiting_time=False, |
| 486 | + ) |
| 487 | + assert stop_phase.automation_state.automation.post_actions.stop_automation is True |
| 488 | + run_stopped = octobot_process_functional_shared._get_action_by_id( |
| 489 | + stop_phase, octobot_process_functional_shared.ACTION_ID_RUN_OCTOBOT |
| 490 | + ) |
| 491 | + assert run_stopped is not None |
| 492 | + assert isinstance(run_stopped.result, dict) |
| 493 | + assert run_stopped.result.get("status") in ("stopped", "already_stopped") |
| 494 | + |
| 495 | + process_deadline = time.monotonic() + octobot_process_functional_shared.CHILD_STOP_WAIT_SEC |
| 496 | + while time.monotonic() < process_deadline: |
| 497 | + if not process_util.pid_is_running(child_pid): |
| 498 | + break |
| 499 | + await asyncio.sleep(0.5) |
| 500 | + else: |
| 501 | + pytest.fail( |
| 502 | + f"expected child pid {child_pid} to be stopped after stop_automation/execution_stop " |
| 503 | + f"within {octobot_process_functional_shared.CHILD_STOP_WAIT_SEC}s" |
| 504 | + ) |
| 505 | + |
| 506 | + finally: |
| 507 | + if os.path.isdir(user_root_guess): |
| 508 | + shutil.rmtree(user_root_guess, ignore_errors=True) |
| 509 | + if os.path.isdir(log_folder_guess): |
| 510 | + shutil.rmtree(log_folder_guess, ignore_errors=True) |
0 commit comments