From 958f236ed708d8aa24ad620db92a0cc066b27c12 Mon Sep 17 00:00:00 2001 From: Johnson Date: Mon, 29 Dec 2025 15:40:55 +0800 Subject: [PATCH 1/2] feat: setup sdk testing environment --- poetry.lock | 372 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 8 ++ pytest.ini | 59 ++++++++ 3 files changed, 435 insertions(+), 4 deletions(-) create mode 100644 pytest.ini diff --git a/poetry.lock b/poetry.lock index 074e6b87..960c8d7d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -150,7 +150,7 @@ description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -556,6 +556,127 @@ files = [ {file = "ckzg-2.1.1.tar.gz", hash = "sha256:d6b306b7ec93a24e4346aa53d07f7f75053bc0afc7398e35fa649e5f9d48fcc4"}, ] +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.13.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, + {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, + {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, + {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, + {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, + {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, + {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, + {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, + {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, + {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, + {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, + {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, + {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, + {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, + {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, + {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, + {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, + {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, + {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, + {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, + {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, + {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, + {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, + {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + [[package]] name = "cytoolz" version = "1.0.1" @@ -858,6 +979,25 @@ dev = ["build (>=0.9.0)", "bump_my_version (>=0.19.0)", "eth-hash[pycryptodome]" docs = ["sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx_rtd_theme (>=1.0.0)", "towncrier (>=24,<25)"] test = ["hypothesis (>=4.43.0)", "mypy (==1.10.0)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "frozenlist" version = "1.6.0" @@ -1016,6 +1156,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "jsonschema" version = "4.23.0" @@ -1170,6 +1322,18 @@ files = [ [package.dependencies] typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + [[package]] name = "parsimonious" version = "0.10.0" @@ -1185,6 +1349,22 @@ files = [ [package.dependencies] regex = ">=2022.3.15" +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "propcache" version = "0.3.1" @@ -1490,6 +1670,136 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "6.3.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"}, + {file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=6.2.5" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-env" +version = "1.2.0" +description = "pytest plugin that allows you to add environment variables." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest_env-1.2.0-py3-none-any.whl", hash = "sha256:d7e5b7198f9b83c795377c09feefa45d56083834e60d04767efd64819fc9da00"}, + {file = "pytest_env-1.2.0.tar.gz", hash = "sha256:475e2ebe8626cee01f491f304a74b12137742397d6c784ea4bc258f069232b80"}, +] + +[package.dependencies] +pytest = ">=8.4.2" +tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["covdefaults (>=2.3)", "coverage (>=7.10.7)", "pytest-mock (>=3.15.1)"] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, + {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + [[package]] name = "python-dotenv" version = "1.1.0" @@ -1892,6 +2202,59 @@ wsproto = "*" dev = ["flake8", "pytest", "pytest-cov", "tox"] docs = ["sphinx"] +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + [[package]] name = "toolz" version = "1.0.0" @@ -1926,11 +2289,12 @@ version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] +markers = {dev = "python_version == \"3.10\""} [[package]] name = "typing-inspection" @@ -2232,4 +2596,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.13" -content-hash = "e52b4b3230b6b817512903deafdb1134b5064873c6e33b49825602b7ac88190a" +content-hash = "cdbfc7b3aa4d3d207f1e988a67db857faa84fce8d0d2ff1c44e0583c6d1acada" diff --git a/pyproject.toml b/pyproject.toml index 17d47e85..d1ce351d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,14 @@ websocket-client = "^1.7.0" jsonschema = "^4.22.0" pydantic-settings = "^2.0" +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.4" +pytest-cov = "^6.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.14.0" +pytest-env = "^1.1.5" +pytest-timeout = "^2.3.1" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..bc1cf42b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,59 @@ +[pytest] +# Test discovery patterns +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Test paths +testpaths = tests + +# Output options +addopts = + # Verbose output + -v + # Show summary of all test types (failed, error, skipped, xfailed, xpassed) + -ra + # Show local variables in tracebacks + --showlocals + # Strict markers - require all markers to be registered + --strict-markers + # Strict config - raise errors on unknown config options + --strict-config + # Coverage options (uncomment when you want coverage reports) + # --cov=virtuals_acp + # --cov-report=html + # --cov-report=term-missing + # --cov-branch + # Disable warnings summary (uncomment to see warnings) + # --disable-warnings + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function + +# Timeout for tests (in seconds) - prevents tests from hanging +timeout = 300 + +# Markers - register custom markers here +markers = + unit: Unit tests + integration: Integration tests + slow: Tests that take a long time to run + requires_network: Tests that require network connectivity + requires_blockchain: Tests that require blockchain connectivity + smoke: Smoke tests for basic functionality + +# Logging +log_cli = false +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(message)s +log_cli_date_format = %Y-%m-%d %H:%M:%S + +# Minimum Python version +minversion = 7.0 + +# Filter warnings +filterwarnings = + error + ignore::UserWarning + ignore::DeprecationWarning From 88d54833d4c2ea83a46c4fb9ca82d361e4e26311 Mon Sep 17 00:00:00 2001 From: johnsonchin Date: Mon, 5 Jan 2026 10:52:09 +0800 Subject: [PATCH 2/2] test: added unit testing and integration testing for client.py - 87% coverage --- tests/conftest.py | 21 + tests/integration/test_client_integration.py | 169 +++ tests/unit/test_client.py | 1290 ++++++++++++++++++ 3 files changed, 1480 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/integration/test_client_integration.py create mode 100644 tests/unit/test_client.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..bda391d1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +import os +from pathlib import Path +import pytest + + +def pytest_configure(config): + """Load environment variables from tests/.env before running tests""" + env_file = Path(__file__).parent / ".env" + if env_file.exists(): + print(f"\n✅ Loading environment variables from {env_file}") + with open(env_file) as f: + for line in f: + line = line.strip() + # Skip comments and empty lines + if line and not line.startswith('#'): + if '=' in line: + key, value = line.split('=', 1) + os.environ[key.strip()] = value.strip() + else: + print(f"\n⚠️ No .env file found at {env_file}") + print("Integration tests will be skipped. Create tests/.env from tests/.env.example") diff --git a/tests/integration/test_client_integration.py b/tests/integration/test_client_integration.py new file mode 100644 index 00000000..756110ae --- /dev/null +++ b/tests/integration/test_client_integration.py @@ -0,0 +1,169 @@ +import pytest +import os +from virtuals_acp.client import VirtualsACP +from virtuals_acp.contract_clients.contract_client_v2 import ACPContractClientV2 +from virtuals_acp.configs.configs import BASE_MAINNET_CONFIG_V2 +from virtuals_acp.models import ACPAgentSort + + +@pytest.mark.integration +class TestClientIntegration: + @pytest.fixture(scope="class") + def acp_client(self): + """Create a real VirtualsACP client for integration testing""" + wallet_private_key = os.getenv("WHITELISTED_WALLET_PRIVATE_KEY") + agent_wallet_address = os.getenv("SELLER_AGENT_WALLET_ADDRESS") + entity_id_str = os.getenv("SELLER_ENTITY_ID") + + if not all([wallet_private_key, agent_wallet_address, entity_id_str]): + pytest.skip("Integration test environment variables not set") + + entity_id = int(entity_id_str) + + contract_client = ACPContractClientV2( + agent_wallet_address=agent_wallet_address, + wallet_private_key=wallet_private_key, + entity_id=entity_id, + config=BASE_MAINNET_CONFIG_V2, + ) + + client = VirtualsACP(acp_contract_clients=contract_client) + yield client + + if hasattr(client, 'sio') and client.sio: + client.sio.disconnect() + + class TestBrowseAgents: + """Integration tests for browse_agents method""" + + def test_should_browse_agents_with_keyword(self, acp_client): + """Should successfully browse agents with keyword search""" + agents = acp_client.browse_agents(keyword="Trading Agent", top_k=5) + + # Verify we got results + assert isinstance(agents, list) + assert len(agents) >= 0 + + # If we got agents, verify their structure + if len(agents) > 0: + agent = agents[0] + assert hasattr(agent, 'id') + assert hasattr(agent, 'wallet_address') + assert hasattr(agent, 'job_offerings') + + def test_should_filter_out_self(self, acp_client): + """Should exclude self from agent search results""" + agents = acp_client.browse_agents( + keyword="Trading Agent", top_k=10) + + # Verify none of the agents are the client itself + for agent in agents: + assert agent.wallet_address.lower() != acp_client.agent_address.lower() + + def test_should_respect_top_k_parameter(self, acp_client): + """Should respect the top_k parameter for result limiting""" + top_k = 3 + agents = acp_client.browse_agents(keyword="", top_k=top_k) + + # Result count should be <= top_k + assert len(agents) <= top_k + + def test_should_handle_sort_by_parameter(self, acp_client): + """Should handle sort_by parameter without errors""" + # This should not raise an error + agents = acp_client.browse_agents( + keyword="", + sort_by=[ACPAgentSort.SUCCESSFUL_JOB_COUNT], + top_k=5 + ) + + assert isinstance(agents, list) + + def test_should_search_with_keyword(self, acp_client): + """Should search agents with specific keyword""" + # Search for something generic + agents = acp_client.browse_agents(keyword="ai", top_k=5) + + assert isinstance(agents, list) + # Even if no results, should return empty list, not error + + class TestGetAgent: + """Integration tests for get_agent method""" + + def test_should_get_own_agent_info(self, acp_client): + """Should successfully retrieve own agent information""" + agent = acp_client.get_agent(acp_client.agent_address) + + # Should return the agent or None + # If the agent exists + if agent: + assert agent.wallet_address.lower() == acp_client.agent_address.lower() + assert hasattr(agent, 'id') + assert hasattr(agent, 'job_offerings') + assert hasattr(agent, 'name') + + def test_should_return_none_for_nonexistent_agent(self, acp_client): + """Should return None for non-existent agent""" + # Use a random address that likely doesn't exist + fake_address = "0x0000000000000000000000000000000000000001" + agent = acp_client.get_agent(fake_address) + + assert agent is None + + def test_should_handle_valid_agent_address(self, acp_client): + """Should handle valid agent address without errors""" + # First browse to find a real agent + agents = acp_client.browse_agents(keyword="", top_k=1) + + if len(agents) > 0: + # Get the first agent's details + agent_address = agents[0].wallet_address + agent = acp_client.get_agent(agent_address) + + # Should return agent info or None + if agent: + assert agent.wallet_address.lower() == agent_address.lower() + + class TestJobFetching: + """Integration tests for job fetching methods""" + + def test_should_fetch_active_jobs(self, acp_client): + """Should successfully fetch active jobs""" + jobs = acp_client.get_active_jobs(page=1, page_size=5) + + assert isinstance(jobs, list) + # Should return a list (could be empty) + + def test_should_fetch_pending_memo_jobs(self, acp_client): + """Should successfully fetch pending memo jobs""" + jobs = acp_client.get_pending_memo_jobs(page=1, page_size=5) + + assert isinstance(jobs, list) + + def test_should_fetch_completed_jobs(self, acp_client): + """Should successfully fetch completed jobs""" + jobs = acp_client.get_completed_jobs(page=1, page_size=5) + + assert isinstance(jobs, list) + + def test_should_fetch_cancelled_jobs(self, acp_client): + """Should successfully fetch cancelled jobs""" + jobs = acp_client.get_cancelled_jobs(page=1, page_size=5) + + assert isinstance(jobs, list) + + class TestAccountMethods: + """Integration tests for account-related methods""" + + def test_get_by_client_and_provider_should_handle_no_account(self, acp_client): + """Should handle case when no account exists between client and provider""" + # Use a random provider address that likely doesn't have an account + fake_provider = "0x0000000000000000000000000000000000000001" + + account = acp_client.get_by_client_and_provider( + acp_client.agent_address, + fake_provider + ) + + # Should return None for non-existent account + assert account is None diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 00000000..15c71870 --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,1290 @@ +import pytest +import json +from unittest.mock import Mock, MagicMock, patch +from datetime import datetime, timezone +from virtuals_acp.client import VirtualsACP +from virtuals_acp.exceptions import ACPError, ACPApiError +from virtuals_acp.models import ACPJobPhase, ACPMemoStatus, MemoType + +# Valid Ethereum addresses for testing +TEST_AGENT_ADDRESS = "0x1234567890123456789012345678901234567890" +TEST_CONTRACT_ADDRESS = "0xABCDEF1234567890123456789012345678901234" +TEST_PROVIDER_ADDRESS = "0x5555555555555555555555555555555555555555" + + +class TestAcpClient: + @pytest.fixture + def mock_contract_client(self): + """Create a mock contract client""" + client = MagicMock() + client.agent_wallet_address = TEST_AGENT_ADDRESS + client.config.acp_api_url = "https://api.example.com" + client.config.contract_address = TEST_CONTRACT_ADDRESS + client.config.chain_id = 8453 # Base Mainnet chain ID + client.config.x402_config = None # Not an x402 contract + client.contract_address = TEST_CONTRACT_ADDRESS + return client + + @pytest.fixture + def acp_client(self, mock_contract_client): + """Create a VirtualsACP client with mocked dependencies""" + with patch('virtuals_acp.client.socketio.Client'): + client = VirtualsACP(acp_contract_clients=mock_contract_client) + return client + + class TestFetchJobList: + """Test _fetch_job_list helper method (network layer)""" + + @patch('virtuals_acp.client.requests.get') + def test_should_fetch_jobs_successfully(self, mock_get, acp_client): + """Should successfully fetch job list from API""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": [ + {"id": 123, "clientAddress": TEST_AGENT_ADDRESS}, + {"id": 456, "providerAddress": TEST_PROVIDER_ADDRESS} + ] + } + mock_get.return_value = mock_response + + url = "https://api.example.com/jobs/active?pagination[page]=1&pagination[pageSize]=10" + jobs = acp_client._fetch_job_list(url) + + # Verify API call + mock_get.assert_called_once_with( + url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + mock_response.raise_for_status.assert_called_once() + + # Verify data extraction + assert len(jobs) == 2 + assert jobs[0]["id"] == 123 + assert jobs[1]["id"] == 456 + + @patch('virtuals_acp.client.requests.get') + def test_should_return_empty_list_when_no_data(self, mock_get, acp_client): + """Should return empty list when API returns no data""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + url = "https://api.example.com/jobs/active" + jobs = acp_client._fetch_job_list(url) + + assert isinstance(jobs, list) + assert len(jobs) == 0 + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_on_network_failure(self, mock_get, acp_client): + """Should raise ACPApiError when network request fails""" + import requests + mock_get.side_effect = requests.RequestException( + "Connection failed") + + url = "https://api.example.com/jobs/active" + with pytest.raises(ACPApiError, match="Failed to fetch ACP jobs"): + acp_client._fetch_job_list(url) + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_on_http_error(self, mock_get, acp_client): + """Should raise error when HTTP request returns error status""" + import requests + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError( + "404 Not Found") + mock_get.return_value = mock_response + + url = "https://api.example.com/jobs/active" + with pytest.raises(ACPApiError, match="Failed to fetch ACP jobs"): + acp_client._fetch_job_list(url) + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_on_invalid_json(self, mock_get, acp_client): + """Should raise ACPApiError when response is not valid JSON""" + mock_response = MagicMock() + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_get.return_value = mock_response + + url = "https://api.example.com/jobs/active" + with pytest.raises(ACPApiError, match="Failed to parse ACP jobs response"): + acp_client._fetch_job_list(url) + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_when_api_returns_error(self, mock_get, acp_client): + """Should raise ACPApiError when API response contains error""" + mock_response = MagicMock() + mock_response.json.return_value = { + "error": { + "message": "Authentication failed" + } + } + mock_get.return_value = mock_response + + url = "https://api.example.com/jobs/active" + with pytest.raises(ACPApiError, match="Authentication failed"): + acp_client._fetch_job_list(url) + + class TestHydrateJobs: + """Test _hydrate_jobs helper method (data transformation layer)""" + + @patch('virtuals_acp.client.ACPJob') + @patch('virtuals_acp.client.ACPMemo') + def test_should_hydrate_jobs_successfully( + self, mock_memo_class, mock_job_class, acp_client + ): + """Should successfully hydrate raw job data into ACPJob objects""" + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + raw_jobs = [ + { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": '{"key": "value"}', + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [] + } + ] + + jobs = acp_client._hydrate_jobs(raw_jobs, log_prefix="Test jobs") + + assert len(jobs) == 1 + assert jobs[0] == mock_job + assert mock_job_class.call_count == 1 + + @patch('virtuals_acp.client.ACPJob') + @patch('virtuals_acp.client.ACPMemo') + def test_should_hydrate_jobs_with_memos( + self, mock_memo_class, mock_job_class, acp_client + ): + """Should properly hydrate jobs with their memos""" + mock_memo = MagicMock() + mock_memo_class.return_value = mock_memo + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + raw_jobs = [ + { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": '{"key": "value"}', + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [ + { + "id": 1, + "memoType": 1, + "content": "Test memo", + "nextPhase": 2, + "status": "PENDING", + "signedReason": None, + "expiry": None, + "payableDetails": None, + "txHash": None, + "signedTxHash": None + } + ] + } + ] + + jobs = acp_client._hydrate_jobs(raw_jobs) + + # Verify memo was created + assert mock_memo_class.call_count == 1 + # Verify job was created + assert mock_job_class.call_count == 1 + + @patch('virtuals_acp.client.ACPJob') + def test_should_parse_json_context(self, mock_job_class, acp_client): + """Should parse JSON context string into dict""" + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + raw_jobs = [ + { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": '{"task": "test", "value": 42}', + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [] + } + ] + + jobs = acp_client._hydrate_jobs(raw_jobs) + + # Verify ACPJob was called with parsed context + call_args = mock_job_class.call_args[1] + assert call_args["context"] == {"task": "test", "value": 42} + + @patch('virtuals_acp.client.ACPJob') + def test_should_handle_invalid_json_context(self, mock_job_class, acp_client): + """Should set context to None when JSON parsing fails""" + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + raw_jobs = [ + { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": "invalid json{{{", + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [] + } + ] + + jobs = acp_client._hydrate_jobs(raw_jobs) + + # Verify ACPJob was called with None context + call_args = mock_job_class.call_args[1] + assert call_args["context"] is None + + @patch('virtuals_acp.client.ACPJob') + def test_should_skip_malformed_jobs(self, mock_job_class, acp_client): + """Should skip jobs that fail to hydrate and continue with valid ones""" + # First call raises error, second succeeds + mock_job_class.side_effect = [ + Exception("Invalid job"), MagicMock()] + + raw_jobs = [ + { + "id": 123, + # Missing required fields - will fail hydration + }, + { + "id": 456, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": None, + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [] + } + ] + + jobs = acp_client._hydrate_jobs(raw_jobs) + + # Should return only the valid job + assert len(jobs) == 1 + + class TestGetActiveJobs: + """Test get_active_jobs public method (integration of fetch + hydrate)""" + + @patch('virtuals_acp.client.requests.get') + @patch('virtuals_acp.client.ACPJob') + @patch('virtuals_acp.client.ACPMemo') + def test_should_get_active_jobs_successfully( + self, mock_memo_class, mock_job_class, mock_get, acp_client + ): + """Should successfully retrieve and hydrate active jobs""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": [ + { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": '{"key": "value"}', + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [] + } + ] + } + mock_get.return_value = mock_response + + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + jobs = acp_client.get_active_jobs(page=1, page_size=10) + + # Verify the API was called with correct URL + expected_url = "https://api.example.com/jobs/active?pagination[page]=1&pagination[pageSize]=10" + mock_get.assert_called_once_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + # Verify jobs were returned + assert isinstance(jobs, list) + assert len(jobs) == 1 + + @patch('virtuals_acp.client.requests.get') + def test_should_use_default_pagination(self, mock_get, acp_client): + """Should use default pagination when not specified""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.get_active_jobs() + + expected_url = "https://api.example.com/jobs/active?pagination[page]=1&pagination[pageSize]=10" + mock_get.assert_called_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + @patch('virtuals_acp.client.requests.get') + def test_should_handle_custom_pagination(self, mock_get, acp_client): + """Should correctly pass custom pagination parameters to API""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.get_active_jobs(page=3, page_size=25) + + expected_url = "https://api.example.com/jobs/active?pagination[page]=3&pagination[pageSize]=25" + mock_get.assert_called_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + class TestGetPendingMemoJobs: + """Test get_pending_memo_jobs public method (integration of fetch + hydrate)""" + + @patch('virtuals_acp.client.requests.get') + @patch('virtuals_acp.client.ACPJob') + @patch('virtuals_acp.client.ACPMemo') + def test_should_get_pending_memo_jobs_successfully( + self, mock_memo_class, mock_job_class, mock_get, acp_client + ): + """Should successfully retrieve and hydrate pending memo jobs""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": [ + { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": '{"key": "value"}', + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [] + } + ] + } + mock_get.return_value = mock_response + + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + jobs = acp_client.get_pending_memo_jobs(page=1, page_size=10) + + expected_url = "https://api.example.com/jobs/pending-memos?pagination[page]=1&pagination[pageSize]=10" + mock_get.assert_called_once_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + @patch('virtuals_acp.client.requests.get') + def test_should_use_default_pagination(self, mock_get, acp_client): + """Should use default pagination when not specified""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.get_pending_memo_jobs() + + expected_url = "https://api.example.com/jobs/pending-memos?pagination[page]=1&pagination[pageSize]=10" + mock_get.assert_called_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + @patch('virtuals_acp.client.requests.get') + def test_should_handle_custom_pagination(self, mock_get, acp_client): + """Should correctly pass custom pagination parameters to API""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.get_pending_memo_jobs(page=3, page_size=25) + + expected_url = "https://api.example.com/jobs/pending-memos?pagination[page]=3&pagination[pageSize]=25" + mock_get.assert_called_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + class TestGetCompletedJobs: + """Test get_completed_jobs public method (integration of fetch + hydrate)""" + + @patch('virtuals_acp.client.requests.get') + @patch('virtuals_acp.client.ACPJob') + @patch('virtuals_acp.client.ACPMemo') + def test_should_get_completed_jobs_successfully( + self, mock_memo_class, mock_job_class, mock_get, acp_client + ): + """Should successfully retrieve and hydrate completed jobs""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": [ + { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": '{"key": "value"}', + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [] + } + ] + } + mock_get.return_value = mock_response + + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + jobs = acp_client.get_completed_jobs(page=1, page_size=10) + + expected_url = "https://api.example.com/jobs/completed?pagination[page]=1&pagination[pageSize]=10" + mock_get.assert_called_once_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + @patch('virtuals_acp.client.requests.get') + def test_should_use_default_pagination(self, mock_get, acp_client): + """Should use default pagination when not specified""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.get_completed_jobs() + + expected_url = "https://api.example.com/jobs/completed?pagination[page]=1&pagination[pageSize]=10" + mock_get.assert_called_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + @patch('virtuals_acp.client.requests.get') + def test_should_handle_custom_pagination(self, mock_get, acp_client): + """Should correctly pass custom pagination parameters to API""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.get_completed_jobs(page=3, page_size=25) + + expected_url = "https://api.example.com/jobs/completed?pagination[page]=3&pagination[pageSize]=25" + mock_get.assert_called_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + class TestGetCancelledJobs: + """Test get_completed_jobs public method (integration of fetch + hydrate)""" + + @patch('virtuals_acp.client.requests.get') + @patch('virtuals_acp.client.ACPJob') + @patch('virtuals_acp.client.ACPMemo') + def test_should_get_cancelled_jobs_successfully( + self, mock_memo_class, mock_job_class, mock_get, acp_client + ): + """Should successfully retrieve and hydrate cancelled jobs""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": [ + { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": '{"key": "value"}', + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [] + } + ] + } + mock_get.return_value = mock_response + + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + jobs = acp_client.get_cancelled_jobs(page=1, page_size=10) + + expected_url = "https://api.example.com/jobs/cancelled?pagination[page]=1&pagination[pageSize]=10" + mock_get.assert_called_once_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + @patch('virtuals_acp.client.requests.get') + def test_should_use_default_pagination(self, mock_get, acp_client): + """Should use default pagination when not specified""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.get_cancelled_jobs() + + expected_url = "https://api.example.com/jobs/cancelled?pagination[page]=1&pagination[pageSize]=10" + mock_get.assert_called_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + @patch('virtuals_acp.client.requests.get') + def test_should_handle_custom_pagination(self, mock_get, acp_client): + """Should correctly pass custom pagination parameters to API""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.get_cancelled_jobs(page=3, page_size=25) + + expected_url = "https://api.example.com/jobs/cancelled?pagination[page]=3&pagination[pageSize]=25" + mock_get.assert_called_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + class TestGetJobByOnchainId: + """Test get_job_by_onchain_id method""" + + @patch('virtuals_acp.client.requests.get') + @patch('virtuals_acp.client.ACPJob') + @patch('virtuals_acp.client.ACPMemo') + def test_should_get_job_by_onchain_id_successfully( + self, mock_memo_class, mock_job_class, mock_get, acp_client + ): + """Should successfully retrieve job by onchain ID""" + mock_memo = MagicMock() + mock_memo_class.return_value = mock_memo + + mock_response = MagicMock() + mock_response.json.return_value = { + "data": { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "100", + "priceTokenAddress": TEST_CONTRACT_ADDRESS, + "phase": 1, + "context": '{"key": "value"}', + "contractAddress": TEST_CONTRACT_ADDRESS, + "netPayableAmount": "90", + "memos": [ + { + "id": 1, + "memoType": 1, + "content": "Test memo", + "nextPhase": 2, + "status": "PENDING", + "signedReason": None, + "expiry": None, + "payableDetails": None, + "txHash": None, + "signedTxHash": None + } + ] + } + } + mock_get.return_value = mock_response + + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + job = acp_client.get_job_by_onchain_id(123) + + # Verify API call + expected_url = "https://api.example.com/jobs/123" + mock_get.assert_called_once_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + # Verify job was created + assert job == mock_job + assert mock_job_class.call_count == 1 + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_on_api_error(self, mock_get, acp_client): + """Should raise ACPApiError when API returns error""" + mock_response = MagicMock() + mock_response.json.return_value = { + "error": { + "message": "Job not found" + } + } + mock_get.return_value = mock_response + + with pytest.raises(ACPApiError, match="Failed to get job by onchain ID"): + acp_client.get_job_by_onchain_id(999) + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_on_network_failure(self, mock_get, acp_client): + """Should raise ACPApiError when network request fails""" + import requests + mock_get.side_effect = requests.RequestException("Connection failed") + + with pytest.raises(ACPApiError, match="Failed to get job by onchain ID"): + acp_client.get_job_by_onchain_id(123) + + @patch('virtuals_acp.client.requests.get') + @patch('virtuals_acp.client.ACPJob') + def test_should_handle_invalid_json_context( + self, mock_job_class, mock_get, acp_client + ): + """Should handle JSONDecodeError when parsing context""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": { + "id": 123, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "evaluatorAddress": TEST_AGENT_ADDRESS, + "price": "1000000", + "priceTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "phase": 0, + "context": "{invalid json}", # Invalid JSON + "memos": [] + } + } + mock_get.return_value = mock_response + + mock_job = MagicMock() + mock_job_class.return_value = mock_job + + job = acp_client.get_job_by_onchain_id(123) + + # Verify job was created with context=None + call_kwargs = mock_job_class.call_args[1] + assert call_kwargs['context'] is None + + class TestGetMemoById: + """Test get_memo_by_id method""" + + @patch('virtuals_acp.client.requests.get') + @patch('virtuals_acp.client.ACPMemo') + def test_should_get_memo_by_id_successfully( + self, mock_memo_class, mock_get, acp_client + ): + """Should successfully retrieve memo by ID""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": { + "id": 1, + "memoType": 1, + "content": "Test memo content", + "nextPhase": 2, + "status": "PENDING", + "signedReason": None, + "expiry": None, + "payableDetails": None, + "txHash": None, + "signedTxHash": None + } + } + mock_get.return_value = mock_response + + mock_memo = MagicMock() + mock_memo_class.return_value = mock_memo + + memo = acp_client.get_memo_by_id(onchain_job_id=123, memo_id=1) + + # Verify API call + expected_url = "https://api.example.com/jobs/123/memos/1" + mock_get.assert_called_once_with( + expected_url, + headers={"wallet-address": TEST_AGENT_ADDRESS} + ) + + # Verify memo was created + assert memo == mock_memo + assert mock_memo_class.call_count == 1 + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_on_api_error(self, mock_get, acp_client): + """Should raise ACPApiError when API returns error""" + mock_response = MagicMock() + mock_response.json.return_value = { + "error": { + "message": "Memo not found" + } + } + mock_get.return_value = mock_response + + with pytest.raises(ACPApiError, match="Failed to get memo by ID"): + acp_client.get_memo_by_id(onchain_job_id=123, memo_id=999) + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_on_network_failure(self, mock_get, acp_client): + """Should raise ACPApiError when network request fails""" + import requests + mock_get.side_effect = requests.RequestException("Connection failed") + + with pytest.raises(ACPApiError, match="Failed to get memo by ID"): + acp_client.get_memo_by_id(onchain_job_id=123, memo_id=1) + + class TestInitialization: + """Test VirtualsACP initialization""" + + @patch('virtuals_acp.client.socketio.Client') + def test_should_initialize_with_single_client(self, mock_socketio, mock_contract_client): + """Should initialize with a single contract client""" + client = VirtualsACP(acp_contract_clients=mock_contract_client) + + assert client.contract_clients == [mock_contract_client] + assert client.contract_client == mock_contract_client + assert client.agent_wallet_address == TEST_AGENT_ADDRESS + + @patch('virtuals_acp.client.socketio.Client') + def test_should_initialize_with_list_of_clients(self, mock_socketio, mock_contract_client): + """Should initialize with a list of contract clients""" + client2 = MagicMock() + client2.agent_wallet_address = TEST_AGENT_ADDRESS + client2.config.acp_api_url = "https://api.example.com" + client2.contract_address = "0x9876543210987654321098765432109876543210" + + client = VirtualsACP(acp_contract_clients=[mock_contract_client, client2]) + + assert len(client.contract_clients) == 2 + assert client.contract_client == mock_contract_client + + @patch('virtuals_acp.client.socketio.Client') + def test_should_raise_error_when_no_clients_provided(self, mock_socketio): + """Should raise ACPError when no clients provided""" + with pytest.raises(ACPError, match="ACP contract client is required"): + VirtualsACP(acp_contract_clients=[]) + + @patch('virtuals_acp.client.socketio.Client') + def test_should_raise_error_when_clients_have_different_addresses( + self, mock_socketio, mock_contract_client + ): + """Should raise error when clients have different agent addresses""" + client2 = MagicMock() + client2.agent_wallet_address = "0x9999999999999999999999999999999999999999" + client2.config.acp_api_url = "https://api.example.com" + + with pytest.raises( + ACPError, + match="All contract clients must have the same agent wallet address" + ): + VirtualsACP(acp_contract_clients=[mock_contract_client, client2]) + + class TestContractClientByAddress: + """Test contract_client_by_address method""" + + def test_should_return_first_client_when_no_address(self, acp_client, mock_contract_client): + """Should return first client when no address provided""" + result = acp_client.contract_client_by_address(None) + assert result == mock_contract_client + + def test_should_find_client_by_address(self, mock_contract_client): + """Should find and return client by contract address""" + client2 = MagicMock() + client2.agent_wallet_address = TEST_AGENT_ADDRESS + client2.config.acp_api_url = "https://api.example.com" + client2.contract_address = "0x9876543210987654321098765432109876543210" + + with patch('virtuals_acp.client.socketio.Client'): + client = VirtualsACP(acp_contract_clients=[mock_contract_client, client2]) + + result = client.contract_client_by_address("0x9876543210987654321098765432109876543210") + assert result == client2 + + def test_should_raise_error_when_client_not_found(self, acp_client): + """Should raise ACPError when client not found by address""" + with pytest.raises(ACPError, match="ACP contract client not found"): + acp_client.contract_client_by_address("0x0000000000000000000000000000000000000000") + + class TestGetByClientAndProvider: + """Test get_by_client_and_provider method""" + + @patch('virtuals_acp.client.requests.get') + @patch('virtuals_acp.client.ACPAccount') + def test_should_get_account_successfully( + self, mock_account_class, mock_get, acp_client + ): + """Should successfully retrieve account by client and provider""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": { + "id": 1, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "metadata": "test metadata" + } + } + mock_response.status_code = 200 + mock_get.return_value = mock_response + + mock_account = MagicMock() + mock_account_class.return_value = mock_account + + account = acp_client.get_by_client_and_provider( + TEST_AGENT_ADDRESS, + TEST_PROVIDER_ADDRESS + ) + + # Verify API call + expected_url = f"https://api.example.com/accounts/client/{TEST_AGENT_ADDRESS}/provider/{TEST_PROVIDER_ADDRESS}" + mock_get.assert_called_once_with(expected_url) + + # Verify account was created + assert account == mock_account + + @patch('virtuals_acp.client.requests.get') + def test_should_return_none_on_404(self, mock_get, acp_client): + """Should return None when account not found (404)""" + mock_response = MagicMock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + + account = acp_client.get_by_client_and_provider( + TEST_AGENT_ADDRESS, + TEST_PROVIDER_ADDRESS + ) + + assert account is None + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_on_network_failure(self, mock_get, acp_client): + """Should raise ACPApiError when network request fails""" + import requests + mock_get.side_effect = requests.RequestException("Connection failed") + + with pytest.raises(ACPApiError, match="Failed to get account by client and provider"): + acp_client.get_by_client_and_provider( + TEST_AGENT_ADDRESS, + TEST_PROVIDER_ADDRESS + ) + + @patch('virtuals_acp.client.requests.get') + def test_should_return_none_when_no_data(self, mock_get, acp_client): + """Should return None when API returns empty data""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": None} + mock_get.return_value = mock_response + + result = acp_client.get_by_client_and_provider( + TEST_AGENT_ADDRESS, + TEST_PROVIDER_ADDRESS + ) + + assert result is None + + @patch('virtuals_acp.client.requests.get') + def test_should_handle_generic_exception(self, mock_get, acp_client): + """Should raise ACPError for generic exceptions""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.side_effect = ValueError("Unexpected error") + mock_get.return_value = mock_response + + with pytest.raises(ACPError, match="An unexpected error occurred while getting account"): + acp_client.get_by_client_and_provider( + TEST_AGENT_ADDRESS, + TEST_PROVIDER_ADDRESS + ) + + class TestGetAccountByJobId: + """Test get_account_by_job_id method""" + + @patch('virtuals_acp.client.requests.get') + @patch('virtuals_acp.client.ACPAccount') + def test_should_get_account_successfully( + self, mock_account_class, mock_get, acp_client + ): + """Should successfully retrieve account by job ID""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": { + "id": 1, + "clientAddress": TEST_AGENT_ADDRESS, + "providerAddress": TEST_PROVIDER_ADDRESS, + "metadata": "test metadata" + } + } + mock_get.return_value = mock_response + + mock_account = MagicMock() + mock_account_class.return_value = mock_account + + account = acp_client.get_account_by_job_id(123) + + # Verify API call + expected_url = "https://api.example.com/accounts/job/123" + mock_get.assert_called_once_with(expected_url) + + # Verify account was created + assert account == mock_account + + @patch('virtuals_acp.client.requests.get') + def test_should_return_none_when_no_data(self, mock_get, acp_client): + """Should return None when no data in response""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": None} + mock_get.return_value = mock_response + + account = acp_client.get_account_by_job_id(123) + + assert account is None + + @patch('virtuals_acp.client.requests.get') + def test_should_raise_error_on_network_failure(self, mock_get, acp_client): + """Should raise ACPApiError when network request fails""" + import requests + mock_get.side_effect = requests.RequestException("Connection failed") + + with pytest.raises(ACPApiError, match="Failed to get account by job id"): + acp_client.get_account_by_job_id(123) + + @patch('virtuals_acp.client.requests.get') + def test_should_handle_generic_exception(self, mock_get, acp_client): + """Should raise ACPError for generic exceptions""" + mock_response = MagicMock() + mock_response.json.side_effect = ValueError("Unexpected error") + mock_get.return_value = mock_response + + with pytest.raises(ACPError, match="An unexpected error occurred while getting account by job id"): + acp_client.get_account_by_job_id(123) + + class TestInitiateJob: + """Test initiate_job method""" + + @pytest.fixture + def mock_fare_amount(self): + """Create a mock FareAmountBase""" + fare = MagicMock() + fare.amount = 100 + fare.fare.contract_address = "0xTokenAddress1234567890123456789012345678" + return fare + + def test_should_raise_error_when_provider_is_self(self, acp_client, mock_fare_amount): + """Should raise ACPError when provider address is same as client""" + with pytest.raises(ACPError, match="Provider address cannot be the same as the client address"): + acp_client.initiate_job( + provider_address=acp_client.agent_address, + service_requirement={"task": "test"}, + fare_amount=mock_fare_amount + ) + + @patch('virtuals_acp.client.VirtualsACP.get_by_client_and_provider') + def test_should_use_create_job_when_no_account_exists( + self, mock_get_account, acp_client, mock_fare_amount + ): + """Should call create_job when no existing account""" + # Mock no existing account + mock_get_account.return_value = None + + # Mock contract client methods + mock_create_op = MagicMock() + acp_client.contract_client.create_job = MagicMock(return_value=mock_create_op) + acp_client.contract_client.handle_operation = MagicMock(return_value="tx_response") + acp_client.contract_client.get_job_id = MagicMock(return_value=42) + + mock_memo_op = MagicMock() + acp_client.contract_client.create_memo = MagicMock(return_value=mock_memo_op) + + job_id = acp_client.initiate_job( + provider_address=TEST_PROVIDER_ADDRESS, + service_requirement={"task": "test"}, + fare_amount=mock_fare_amount + ) + + # Verify create_job was called (not create_job_with_account) + acp_client.contract_client.create_job.assert_called_once() + assert job_id == 42 + + @patch('virtuals_acp.client.VirtualsACP.get_by_client_and_provider') + def test_should_use_create_job_with_account_when_account_exists( + self, mock_get_account, acp_client, mock_fare_amount + ): + """Should call create_job_with_account when account exists""" + # Mock existing account + mock_account = MagicMock() + mock_account.id = 5 + mock_get_account.return_value = mock_account + + # Mock contract client methods + mock_create_op = MagicMock() + acp_client.contract_client.create_job_with_account = MagicMock(return_value=mock_create_op) + acp_client.contract_client.handle_operation = MagicMock(return_value="tx_response") + acp_client.contract_client.get_job_id = MagicMock(return_value=43) + + mock_memo_op = MagicMock() + acp_client.contract_client.create_memo = MagicMock(return_value=mock_memo_op) + + # Set config to NOT be a base contract (to trigger account path) + acp_client.contract_client.config.contract_address = "0xCustomContract123456789012345678901234567" + + job_id = acp_client.initiate_job( + provider_address=TEST_PROVIDER_ADDRESS, + service_requirement={"task": "test"}, + fare_amount=mock_fare_amount + ) + + # Verify create_job_with_account was called with account ID + acp_client.contract_client.create_job_with_account.assert_called_once() + call_args = acp_client.contract_client.create_job_with_account.call_args[0] + assert call_args[0] == 5 # account.id + assert job_id == 43 + + @patch('virtuals_acp.client.VirtualsACP.get_by_client_and_provider') + def test_should_convert_dict_requirement_to_json( + self, mock_get_account, acp_client, mock_fare_amount + ): + """Should convert dictionary service requirement to JSON string""" + mock_get_account.return_value = None + + mock_create_op = MagicMock() + acp_client.contract_client.create_job = MagicMock(return_value=mock_create_op) + acp_client.contract_client.handle_operation = MagicMock(return_value="tx_response") + acp_client.contract_client.get_job_id = MagicMock(return_value=44) + + mock_memo_op = MagicMock() + acp_client.contract_client.create_memo = MagicMock(return_value=mock_memo_op) + + requirement_dict = {"task": "translate", "language": "spanish"} + + acp_client.initiate_job( + provider_address=TEST_PROVIDER_ADDRESS, + service_requirement=requirement_dict, + fare_amount=mock_fare_amount + ) + + # Verify create_memo was called with JSON string + acp_client.contract_client.create_memo.assert_called_once() + call_args = acp_client.contract_client.create_memo.call_args[0] + + # The second argument should be the JSON-stringified requirement + import json + assert json.loads(call_args[1]) == requirement_dict + + @patch('virtuals_acp.client.VirtualsACP.get_by_client_and_provider') + def test_should_use_string_requirement_as_is( + self, mock_get_account, acp_client, mock_fare_amount + ): + """Should use string service requirement without modification""" + mock_get_account.return_value = None + + mock_create_op = MagicMock() + acp_client.contract_client.create_job = MagicMock(return_value=mock_create_op) + acp_client.contract_client.handle_operation = MagicMock(return_value="tx_response") + acp_client.contract_client.get_job_id = MagicMock(return_value=45) + + mock_memo_op = MagicMock() + acp_client.contract_client.create_memo = MagicMock(return_value=mock_memo_op) + + requirement_str = "Please translate this document" + + acp_client.initiate_job( + provider_address=TEST_PROVIDER_ADDRESS, + service_requirement=requirement_str, + fare_amount=mock_fare_amount + ) + + # Verify create_memo was called with the string as-is + acp_client.contract_client.create_memo.assert_called_once() + call_args = acp_client.contract_client.create_memo.call_args[0] + assert call_args[1] == requirement_str + + @patch('virtuals_acp.client.VirtualsACP.get_by_client_and_provider') + def test_should_use_default_expiry_if_not_provided( + self, mock_get_account, acp_client, mock_fare_amount + ): + """Should set expiry to 1 day from now if not provided""" + from datetime import datetime, timezone, timedelta + + mock_get_account.return_value = None + + mock_create_op = MagicMock() + acp_client.contract_client.create_job = MagicMock(return_value=mock_create_op) + acp_client.contract_client.handle_operation = MagicMock(return_value="tx_response") + acp_client.contract_client.get_job_id = MagicMock(return_value=46) + + mock_memo_op = MagicMock() + acp_client.contract_client.create_memo = MagicMock(return_value=mock_memo_op) + + before = datetime.now(timezone.utc) + timedelta(days=1) + + acp_client.initiate_job( + provider_address=TEST_PROVIDER_ADDRESS, + service_requirement="test", + fare_amount=mock_fare_amount + # Note: no expired_at provided + ) + + after = datetime.now(timezone.utc) + timedelta(days=1) + + # Verify create_job was called with an expiry around 1 day from now + acp_client.contract_client.create_job.assert_called_once() + call_args = acp_client.contract_client.create_job.call_args[0] + expired_at = call_args[2] # Third argument is expired_at + + # Should be within a few seconds of 1 day from now + assert before <= expired_at <= after + + @patch('virtuals_acp.client.VirtualsACP.get_by_client_and_provider') + def test_should_use_custom_evaluator_address( + self, mock_get_account, acp_client, mock_fare_amount + ): + """Should use custom evaluator address if provided""" + mock_get_account.return_value = None + + mock_create_op = MagicMock() + acp_client.contract_client.create_job = MagicMock(return_value=mock_create_op) + acp_client.contract_client.handle_operation = MagicMock(return_value="tx_response") + acp_client.contract_client.get_job_id = MagicMock(return_value=47) + + mock_memo_op = MagicMock() + acp_client.contract_client.create_memo = MagicMock(return_value=mock_memo_op) + + custom_evaluator = "0x7777777777777777777777777777777777777777" + + acp_client.initiate_job( + provider_address=TEST_PROVIDER_ADDRESS, + service_requirement="test", + fare_amount=mock_fare_amount, + evaluator_address=custom_evaluator + ) + + # Verify create_job was called with custom evaluator + acp_client.contract_client.create_job.assert_called_once() + call_args = acp_client.contract_client.create_job.call_args[0] + + # Second argument is evaluator address + from web3 import Web3 + assert call_args[1] == Web3.to_checksum_address(custom_evaluator) + + class TestProperties: + """Test property accessors""" + + def test_should_access_acp_contract_client_property(self, acp_client): + """Should access backward compatibility property acp_contract_client""" + assert acp_client.acp_contract_client == acp_client.contract_clients[0] + + class TestBrowseAgents: + """Test browse_agents method""" + + @patch('virtuals_acp.client.requests.get') + def test_should_include_cluster_in_url(self, mock_get, acp_client): + """Should include cluster parameter in URL when provided""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.browse_agents(keyword="test", cluster="ai-agents") + + # Verify URL includes cluster parameter + called_url = mock_get.call_args[0][0] + assert "&cluster=ai-agents" in called_url + + @patch('virtuals_acp.client.requests.get') + def test_should_include_graduation_status_in_url(self, mock_get, acp_client): + """Should include graduation_status parameter in URL when provided""" + from virtuals_acp.models import ACPGraduationStatus + + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.browse_agents( + keyword="test", + graduation_status=ACPGraduationStatus.GRADUATED + ) + + # Verify URL includes graduation status parameter + called_url = mock_get.call_args[0][0] + assert f"&graduationStatus={ACPGraduationStatus.GRADUATED.value}" in called_url + + @patch('virtuals_acp.client.requests.get') + def test_should_include_online_status_in_url(self, mock_get, acp_client): + """Should include online_status parameter in URL when provided""" + from virtuals_acp.models import ACPOnlineStatus + + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.browse_agents( + keyword="test", + online_status=ACPOnlineStatus.ONLINE + ) + + # Verify URL includes online status parameter + called_url = mock_get.call_args[0][0] + assert f"&onlineStatus={ACPOnlineStatus.ONLINE.value}" in called_url + + @patch('virtuals_acp.client.requests.get') + def test_should_include_show_hidden_offerings_in_url(self, mock_get, acp_client): + """Should include showHiddenOfferings parameter in URL when true""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_get.return_value = mock_response + + acp_client.browse_agents( + keyword="test", + show_hidden_offerings=True + ) + + # Verify URL includes showHiddenOfferings parameter + called_url = mock_get.call_args[0][0] + assert "&showHiddenOfferings=true" in called_url + + @patch('virtuals_acp.client.requests.get') + def test_should_handle_generic_exception(self, mock_get, acp_client): + """Should raise ACPError for generic exceptions in browse_agents""" + mock_get.side_effect = ValueError("Unexpected error") + + with pytest.raises(ACPError, match="An unexpected error occurred while browsing agents"): + acp_client.browse_agents(keyword="test") + + class TestGetAgent: + """Test get_agent method""" + + @patch('virtuals_acp.client.requests.get') + def test_should_handle_generic_exception(self, mock_get, acp_client): + """Should raise ACPError for generic exceptions in get_agent""" + mock_get.side_effect = ValueError("Unexpected error") + + with pytest.raises(ACPError, match="An unexpected error occurred while getting agent"): + acp_client.get_agent(TEST_AGENT_ADDRESS)