|
16 | 16 | build_order_preview_receipt, |
17 | 17 | find_recent_mutation_by_id, |
18 | 18 | ) |
| 19 | +from toss_browser_bridge.daemon import classify_broker_reject |
19 | 20 |
|
20 | 21 |
|
21 | 22 | class FakePage: |
@@ -558,3 +559,238 @@ def test_final_submit_can_be_explicitly_unblocked_for_manual_non_pytest_runtime( |
558 | 559 |
|
559 | 560 | assert runtime._final_submit_enabled is True |
560 | 561 | assert runtime._mutation_runtime_state()["final_submit_guard_reason"] == "enabled_by_env" |
| 562 | + |
| 563 | + |
| 564 | +def test_classify_broker_reject_maps_status_codes_to_enum() -> None: |
| 565 | + assert classify_broker_reject(message=None, status_code=0, error="EAI_AGAIN") == "BROKER_REJECTED_TIMEOUT" |
| 566 | + assert classify_broker_reject(message="gateway timeout", status_code=504, error=None) == "BROKER_REJECTED_TIMEOUT" |
| 567 | + assert classify_broker_reject(message="forbidden", status_code=403, error=None) == "BROKER_REJECTED_AUTH_REQUIRED" |
| 568 | + assert classify_broker_reject(message="unauthorized", status_code=401, error=None) == "BROKER_REJECTED_AUTH_REQUIRED" |
| 569 | + assert classify_broker_reject(message="server error", status_code=500, error=None) == "BROKER_REJECTED_HTTP_ERROR" |
| 570 | + assert classify_broker_reject(message="unknown", status_code=200, error=None) == "BROKER_REJECTED_UNKNOWN" |
| 571 | + assert classify_broker_reject(message=None, status_code=400, error=None) == "BROKER_REJECTED_UNKNOWN" |
| 572 | + |
| 573 | + |
| 574 | +def _enable_final_submit(monkeypatch) -> None: |
| 575 | + monkeypatch.setenv("TOSS_BRIDGE_ENABLE_FINAL_SUBMIT", "1") |
| 576 | + monkeypatch.setenv("TOSS_BRIDGE_ALLOW_TEST_FINAL_SUBMIT", "1") |
| 577 | + |
| 578 | + |
| 579 | +def _stub_place_order_dependencies(runtime, monkeypatch, tmp_path, fingerprint) -> None: |
| 580 | + monkeypatch.setattr(daemon_module, "MUTATION_JOURNAL_FILE", tmp_path / "mutation-journal.jsonl") |
| 581 | + runtime.order_preview = MethodType( |
| 582 | + lambda self, params: { |
| 583 | + "ok": True, |
| 584 | + "kind": "order_preview", |
| 585 | + "checked_at": "2026-05-04T23:40:00+09:00", |
| 586 | + "diagnostics": {"endpoint_matrix": [], "last_errors": []}, |
| 587 | + "data": { |
| 588 | + "preview_state": "preview_ready", |
| 589 | + "preview_fingerprint": fingerprint, |
| 590 | + }, |
| 591 | + }, |
| 592 | + runtime, |
| 593 | + ) |
| 594 | + runtime._run_prepare_preflight = MethodType( |
| 595 | + lambda self, normalized: { |
| 596 | + "message": "prepare preflight succeeded", |
| 597 | + "context": self._make_context([]), |
| 598 | + "account_no": "44258118-01", |
| 599 | + "submit_market": "NSQ", |
| 600 | + "currency_mode": "USD", |
| 601 | + "allow_auto_exchange": False, |
| 602 | + "prepare_payload": { |
| 603 | + "stockCode": "US0378331005", |
| 604 | + "tradeType": "buy", |
| 605 | + "market": "NSQ", |
| 606 | + "currencyMode": "USD", |
| 607 | + "price": 200.0, |
| 608 | + "quantity": 1, |
| 609 | + "orderAmount": 0, |
| 610 | + "orderPriceType": "00", |
| 611 | + "agreedOver100Million": False, |
| 612 | + "marginTrading": False, |
| 613 | + "max": False, |
| 614 | + "isReservationOrder": False, |
| 615 | + "openPriceSinglePriceYn": False, |
| 616 | + "withOrderKey": True, |
| 617 | + }, |
| 618 | + "prepare_body": {"orderKey": "trade::session::stub"}, |
| 619 | + "prepared_order_info": {"price": 200.0, "quantity": 1}, |
| 620 | + }, |
| 621 | + runtime, |
| 622 | + ) |
| 623 | + |
| 624 | + |
| 625 | +def test_place_order_submits_when_broker_create_returns_order_id(tmp_path, monkeypatch) -> None: |
| 626 | + _enable_final_submit(monkeypatch) |
| 627 | + runtime = _runtime() |
| 628 | + receipt = _receipt() |
| 629 | + _stub_place_order_dependencies(runtime, monkeypatch, tmp_path, receipt["preview_fingerprint"]) |
| 630 | + runtime._fetch_many = MethodType( |
| 631 | + lambda self, requests: [ |
| 632 | + { |
| 633 | + "name": "order_create", |
| 634 | + "method": "POST", |
| 635 | + "path": "/api/v2/wts/trading/order/create", |
| 636 | + "status_code": 200, |
| 637 | + "ok": True, |
| 638 | + "json": { |
| 639 | + "result": { |
| 640 | + "message": "AAPL 매수 주문 완료", |
| 641 | + "orderDate": "2026-05-04", |
| 642 | + "orderNo": 3, |
| 643 | + "isReserved": False, |
| 644 | + "orderId": "V56qyv7r", |
| 645 | + } |
| 646 | + }, |
| 647 | + "text": "", |
| 648 | + "error": None, |
| 649 | + } |
| 650 | + ], |
| 651 | + runtime, |
| 652 | + ) |
| 653 | + |
| 654 | + response = runtime.place_order( |
| 655 | + { |
| 656 | + "preview_receipt": receipt, |
| 657 | + "preview_fingerprint": receipt["preview_fingerprint"], |
| 658 | + "confirm": True, |
| 659 | + "confirm_text": "BUY 1 AAPL LIMIT 200.00 US", |
| 660 | + } |
| 661 | + ) |
| 662 | + |
| 663 | + assert response["ok"] is True |
| 664 | + data = response["data"] |
| 665 | + assert data["submit_state"] == "submitted" |
| 666 | + assert data["broker_ack"]["status"] == "submitted" |
| 667 | + assert data["broker_ack"]["code"] == "OK" |
| 668 | + assert data["broker_ack"]["broker_order_id"] == "V56qyv7r" |
| 669 | + assert data["broker_ack"]["order_no"] == 3 |
| 670 | + assert data["broker_ack"]["order_date"] == "2026-05-04" |
| 671 | + assert data["broker_ack"]["http_status"] == 200 |
| 672 | + |
| 673 | + |
| 674 | +def test_place_order_marks_broker_rejected_when_orderid_missing(tmp_path, monkeypatch) -> None: |
| 675 | + _enable_final_submit(monkeypatch) |
| 676 | + runtime = _runtime() |
| 677 | + receipt = _receipt() |
| 678 | + _stub_place_order_dependencies(runtime, monkeypatch, tmp_path, receipt["preview_fingerprint"]) |
| 679 | + runtime._fetch_many = MethodType( |
| 680 | + lambda self, requests: [ |
| 681 | + { |
| 682 | + "name": "order_create", |
| 683 | + "method": "POST", |
| 684 | + "path": "/api/v2/wts/trading/order/create", |
| 685 | + "status_code": 200, |
| 686 | + "ok": True, |
| 687 | + "json": {"result": {"message": "주문 거절"}}, |
| 688 | + "text": "", |
| 689 | + "error": None, |
| 690 | + } |
| 691 | + ], |
| 692 | + runtime, |
| 693 | + ) |
| 694 | + |
| 695 | + response = runtime.place_order( |
| 696 | + { |
| 697 | + "preview_receipt": receipt, |
| 698 | + "preview_fingerprint": receipt["preview_fingerprint"], |
| 699 | + "confirm": True, |
| 700 | + "confirm_text": "BUY 1 AAPL LIMIT 200.00 US", |
| 701 | + } |
| 702 | + ) |
| 703 | + |
| 704 | + data = response["data"] |
| 705 | + assert data["submit_state"] == "broker_rejected" |
| 706 | + assert data["broker_ack"]["code"] == "BROKER_REJECTED_UNKNOWN" |
| 707 | + assert "broker_order_id" not in data["broker_ack"] |
| 708 | + |
| 709 | + |
| 710 | +def test_place_order_marks_broker_rejected_with_timeout_on_runtime_error(tmp_path, monkeypatch) -> None: |
| 711 | + _enable_final_submit(monkeypatch) |
| 712 | + runtime = _runtime() |
| 713 | + receipt = _receipt() |
| 714 | + _stub_place_order_dependencies(runtime, monkeypatch, tmp_path, receipt["preview_fingerprint"]) |
| 715 | + |
| 716 | + def _raise_timeout(self, requests): |
| 717 | + raise RuntimeError("browser request timed out: 30000ms") |
| 718 | + |
| 719 | + runtime._fetch_many = MethodType(_raise_timeout, runtime) |
| 720 | + |
| 721 | + response = runtime.place_order( |
| 722 | + { |
| 723 | + "preview_receipt": receipt, |
| 724 | + "preview_fingerprint": receipt["preview_fingerprint"], |
| 725 | + "confirm": True, |
| 726 | + "confirm_text": "BUY 1 AAPL LIMIT 200.00 US", |
| 727 | + } |
| 728 | + ) |
| 729 | + |
| 730 | + data = response["data"] |
| 731 | + assert data["submit_state"] == "broker_rejected" |
| 732 | + assert data["broker_ack"]["code"] == "BROKER_REJECTED_TIMEOUT" |
| 733 | + assert data["broker_ack"]["http_status"] == 0 |
| 734 | + |
| 735 | + |
| 736 | +def test_place_order_auto_verify_attaches_verify_snapshot_when_submitted(tmp_path, monkeypatch) -> None: |
| 737 | + _enable_final_submit(monkeypatch) |
| 738 | + runtime = _runtime() |
| 739 | + receipt = _receipt() |
| 740 | + _stub_place_order_dependencies(runtime, monkeypatch, tmp_path, receipt["preview_fingerprint"]) |
| 741 | + runtime._fetch_many = MethodType( |
| 742 | + lambda self, requests: [ |
| 743 | + { |
| 744 | + "name": "order_create", |
| 745 | + "method": "POST", |
| 746 | + "path": "/api/v2/wts/trading/order/create", |
| 747 | + "status_code": 200, |
| 748 | + "ok": True, |
| 749 | + "json": { |
| 750 | + "result": { |
| 751 | + "message": "AAPL 매수 주문 완료", |
| 752 | + "orderDate": "2026-05-04", |
| 753 | + "orderNo": 3, |
| 754 | + "isReserved": False, |
| 755 | + "orderId": "V56qyv7r", |
| 756 | + } |
| 757 | + }, |
| 758 | + "text": "", |
| 759 | + "error": None, |
| 760 | + } |
| 761 | + ], |
| 762 | + runtime, |
| 763 | + ) |
| 764 | + verify_calls: list[dict] = [] |
| 765 | + |
| 766 | + def _fake_verify_order(self, params): |
| 767 | + verify_calls.append(params) |
| 768 | + return { |
| 769 | + "ok": True, |
| 770 | + "kind": "verify_order", |
| 771 | + "data": { |
| 772 | + "verification_state": "verified_success", |
| 773 | + "verify_snapshot": { |
| 774 | + "status": "verified_success", |
| 775 | + "matched_order": {"order_no": 3}, |
| 776 | + "verified_at": "2026-05-04T23:41:00+09:00", |
| 777 | + }, |
| 778 | + }, |
| 779 | + } |
| 780 | + |
| 781 | + runtime.verify_order = MethodType(_fake_verify_order, runtime) |
| 782 | + |
| 783 | + response = runtime.place_order( |
| 784 | + { |
| 785 | + "preview_receipt": receipt, |
| 786 | + "preview_fingerprint": receipt["preview_fingerprint"], |
| 787 | + "confirm": True, |
| 788 | + "confirm_text": "BUY 1 AAPL LIMIT 200.00 US", |
| 789 | + "auto_verify": True, |
| 790 | + } |
| 791 | + ) |
| 792 | + |
| 793 | + data = response["data"] |
| 794 | + assert len(verify_calls) == 1 |
| 795 | + assert data["verification_state"] == "verified_success" |
| 796 | + assert data["verify_snapshot"]["matched_order"]["order_no"] == 3 |
0 commit comments