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 7f528f3d9a54558171beef3202710a9e51f374c3 Mon Sep 17 00:00:00 2001 From: johnsonchin Date: Tue, 6 Jan 2026 15:47:58 +0800 Subject: [PATCH 2/2] test: added unit testing for job.py - 99% coverage - does not include integration test, will approach this separately --- tests/unit/test_job.py | 1242 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1242 insertions(+) create mode 100644 tests/unit/test_job.py diff --git a/tests/unit/test_job.py b/tests/unit/test_job.py new file mode 100644 index 00000000..9af9d3ea --- /dev/null +++ b/tests/unit/test_job.py @@ -0,0 +1,1242 @@ +import pytest +import json +from unittest.mock import MagicMock, patch +from datetime import datetime, timezone, timedelta +# Import client first to trigger model_rebuild() +from virtuals_acp.client import VirtualsACP +from virtuals_acp.job import ACPJob +from virtuals_acp.memo import ACPMemo +from virtuals_acp.models import ( + ACPJobPhase, + MemoType, + ACPMemoStatus, + PriceType, + DeliverablePayload, + FeeType, + OperationPayload, +) +from virtuals_acp.fare import Fare, FareAmount + +TEST_AGENT_ADDRESS = "0x1234567890123456789012345678901234567890" +TEST_PROVIDER_ADDRESS = "0x5555555555555555555555555555555555555555" +TEST_CONTRACT_ADDRESS = "0xABCDEF1234567890123456789012345678901234" +TEST_EVALUATOR_ADDRESS = "0x9999999999999999999999999999999999999999" + + +class TestACPJob: + @pytest.fixture + def mock_acp_client(self): + """Create a mock VirtualsACP client""" + client = MagicMock() + base_fare = Fare( + contract_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + decimals=6 + ) + client.config.base_fare = base_fare + client.contract_client.config.base_fare = base_fare + # Mock format_amount to return the value directly (for testing) + client.contract_client_by_address.return_value.config.base_fare.format_amount = lambda x: int( + x) + return client + + @pytest.fixture + def mock_contract_client(self): + """Create a mock contract client""" + client = MagicMock() + client.config.base_fare = Fare( + contract_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + decimals=6 + ) + return client + + @pytest.fixture + def sample_job_data(self, mock_acp_client): + """Sample job data for testing""" + return { + "acp_client": mock_acp_client, + "id": 123, + "client_address": TEST_AGENT_ADDRESS, + "provider_address": TEST_PROVIDER_ADDRESS, + "evaluator_address": TEST_EVALUATOR_ADDRESS, + "price": 100.0, + "price_token_address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "memos": [], + "phase": ACPJobPhase.REQUEST, + "context": {"task": "test"}, + "contract_address": TEST_CONTRACT_ADDRESS, + "net_payable_amount": 95.0 + } + + @pytest.fixture + def basic_job(self, sample_job_data): + """Create a basic ACPJob instance for testing""" + # Use model_construct to bypass Pydantic validation for mocks + return ACPJob.model_construct(**sample_job_data) + + @pytest.fixture + def complete_x402_response(self): + """Provide complete X402PayableRequirements mock data""" + return { + "isPaymentRequired": True, + "data": { + "x402Version": 1, + "error": "", + "accepts": [{ + "scheme": "eip-3009", + "network": "base", + "resource": "0x1111111111111111111111111111111111111111", + "description": "Payment for AI service", + "mimeType": "application/json", + "payTo": "0x1111111111111111111111111111111111111111", + "maxAmountRequired": "1000000", + "maxTimeoutSeconds": 300, + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "extra": { + "name": "AI Service Payment", + "version": "1.0.0" + }, + "outputSchema": {} + }] + } + } + + class TestInitialization: + """Test job initialization and model_post_init""" + + def test_should_initialize_with_all_parameters(self, sample_job_data): + """Should correctly initialize job with all parameters""" + job = ACPJob.model_construct(**sample_job_data) + + assert job.id == 123 + assert job.client_address == TEST_AGENT_ADDRESS + assert job.provider_address == TEST_PROVIDER_ADDRESS + assert job.evaluator_address == TEST_EVALUATOR_ADDRESS + assert job.price == 100.0 + assert job.phase == ACPJobPhase.REQUEST + assert job.context == {"task": "test"} + assert job.contract_address == TEST_CONTRACT_ADDRESS + + def test_should_set_base_fare_from_acp_client_config(self, sample_job_data): + """Should set _base_fare from acp_client config during init""" + job = ACPJob.model_construct(**sample_job_data) + + assert job._base_fare is not None + assert job._base_fare.decimals == 6 + + def test_should_parse_negotiation_memo_content(self, sample_job_data, mock_contract_client): + """Should parse requirement from NEGOTIATION memo during init""" + negotiation_memo = MagicMock(spec=ACPMemo) + negotiation_memo.next_phase = ACPJobPhase.NEGOTIATION + negotiation_memo.content = json.dumps({ + "service_requirement": "Build an AI agent", + "service_name": "AI Development", + "price_type": "FIXED", + "price_value": 500.0 + }) + + sample_job_data["memos"] = [negotiation_memo] + + with patch('virtuals_acp.job.try_parse_json_model') as mock_parse: + from virtuals_acp.models import RequestPayload + mock_payload = MagicMock() + mock_payload.service_requirement = "Build an AI agent" + mock_payload.service_name = "AI Development" + mock_payload.price_type = PriceType.FIXED + mock_payload.price_value = 10.0 + mock_parse.return_value = mock_payload + + job = ACPJob.model_construct(**sample_job_data) + + assert job._requirement == "Build an AI agent" + assert job._name == "AI Development" + assert job._price_type == PriceType.FIXED + assert job._price_value == 10.0 + + def test_should_handle_missing_negotiation_memo(self, sample_job_data): + """Should handle case when no NEGOTIATION memo exists""" + job = ACPJob.model_construct(**sample_job_data) + + # Should not crash and defaults should be set + assert job._requirement is None + assert job._name is None + assert job._price_type == PriceType.FIXED + assert job._price_value == 0.0 + + def test_should_handle_empty_memo_content(self, sample_job_data): + """Should handle NEGOTIATION memo with empty content""" + negotiation_memo = MagicMock(spec=ACPMemo) + negotiation_memo.next_phase = ACPJobPhase.NEGOTIATION + negotiation_memo.content = None + + sample_job_data["memos"] = [negotiation_memo] + job = ACPJob.model_construct(**sample_job_data) + + assert job._requirement is None + assert job._name is None + + def test_should_handle_unparseable_memo_content(self, sample_job_data): + """Should handle NEGOTIATION memo with unparseable content""" + negotiation_memo = MagicMock(spec=ACPMemo) + negotiation_memo.next_phase = ACPJobPhase.NEGOTIATION + negotiation_memo.content = "{invalid json}" + + sample_job_data["memos"] = [negotiation_memo] + + with patch('virtuals_acp.job.try_parse_json_model', return_value=None): + job = ACPJob.model_construct(**sample_job_data) + + assert job._requirement is None + assert job._name is None + + class TestProperties: + """Test property accessors""" + + def test_requirement_should_return_private_attribute(self, basic_job): + """Should return _requirement via property""" + basic_job._requirement = "Test requirement" + assert basic_job.requirement == "Test requirement" + + def test_name_should_return_private_attribute(self, basic_job): + """Should return _name via property""" + basic_job._name = "Test Job" + assert basic_job.name == "Test Job" + + def test_price_type_should_return_private_attribute(self, basic_job): + """Should return _price_type via property""" + basic_job._price_type = PriceType.PERCENTAGE + assert basic_job.price_type == PriceType.PERCENTAGE + + def test_price_value_should_return_private_attribute(self, basic_job): + """Should return _price_value via property""" + basic_job._price_value = 250.0 + assert basic_job.price_value == 250.0 + + def test_acp_contract_client_should_return_default_client_when_no_contract_address( + self, basic_job, mock_acp_client + ): + """Should return default contract client when no contract_address""" + basic_job.contract_address = None + + result = basic_job.acp_contract_client + + assert result == mock_acp_client.contract_client + + def test_acp_contract_client_should_find_client_by_address( + self, basic_job, mock_acp_client + ): + """Should find contract client by address when contract_address is set""" + specific_client = MagicMock() + mock_acp_client.contract_client_by_address.return_value = specific_client + + result = basic_job.acp_contract_client + + mock_acp_client.contract_client_by_address.assert_called_once_with( + TEST_CONTRACT_ADDRESS + ) + assert result == specific_client + + def test_config_should_return_contract_client_config(self, basic_job, mock_acp_client): + """Should return config from acp_contract_client""" + mock_config = MagicMock() + mock_acp_client.contract_client_by_address.return_value.config = mock_config + + result = basic_job.config + + assert result == mock_config + + def test_base_fare_should_return_config_base_fare(self, basic_job, mock_acp_client): + """Should return base_fare from contract client config""" + mock_fare = Fare( + contract_address="0x1111111111111111111111111111111111111111", decimals=18) + mock_acp_client.contract_client_by_address.return_value.config.base_fare = mock_fare + + result = basic_job.base_fare + + assert result == mock_fare + + def test_account_should_fetch_account_by_job_id(self, basic_job, mock_acp_client): + """Should fetch account using acp_client.get_account_by_job_id""" + mock_account = MagicMock() + mock_acp_client.get_account_by_job_id.return_value = mock_account + + result = basic_job.account + + mock_acp_client.get_account_by_job_id.assert_called_once_with( + 123, # job.id + mock_acp_client.contract_client_by_address.return_value + ) + assert result == mock_account + + def test_deliverable_should_return_completed_memo_content(self, basic_job): + """Should return content from COMPLETED memo""" + memo1 = MagicMock(spec=ACPMemo) + memo1.next_phase = ACPJobPhase.NEGOTIATION + memo1.content = "Request" + + memo2 = MagicMock(spec=ACPMemo) + memo2.next_phase = ACPJobPhase.COMPLETED + memo2.content = "Deliverable result" + + basic_job.memos = [memo1, memo2] + + assert basic_job.deliverable == "Deliverable result" + + def test_deliverable_should_return_none_when_no_completed_memo(self, basic_job): + """Should return None when no COMPLETED memo exists""" + memo = MagicMock(spec=ACPMemo) + memo.next_phase = ACPJobPhase.NEGOTIATION + basic_job.memos = [memo] + + assert basic_job.deliverable is None + + def test_rejection_reason_should_return_none_when_not_rejected(self, basic_job): + """Should return None when job phase is not REJECTED""" + basic_job.phase = ACPJobPhase.REQUEST + + assert basic_job.rejection_reason is None + + def test_rejection_reason_should_return_signed_reason_from_request_memo( + self, basic_job + ): + """Should return signed_reason from NEGOTIATION memo when rejected""" + basic_job.phase = ACPJobPhase.REJECTED + + memo = MagicMock(spec=ACPMemo) + memo.next_phase = ACPJobPhase.NEGOTIATION + memo.signed_reason = "Not acceptable" + + basic_job.memos = [memo] + + assert basic_job.rejection_reason == "Not acceptable" + + def test_rejection_reason_should_fallback_to_rejected_memo_content( + self, basic_job + ): + """Should return content from REJECTED memo as fallback""" + basic_job.phase = ACPJobPhase.REJECTED + + memo = MagicMock(spec=ACPMemo) + memo.next_phase = ACPJobPhase.REJECTED + memo.content = "Fallback reason" + + basic_job.memos = [memo] + + assert basic_job.rejection_reason == "Fallback reason" + + def test_provider_agent_should_fetch_agent_by_provider_address( + self, basic_job, mock_acp_client + ): + """Should fetch provider agent using get_agent""" + mock_agent = MagicMock() + mock_acp_client.get_agent.return_value = mock_agent + + result = basic_job.provider_agent + + mock_acp_client.get_agent.assert_called_once_with( + TEST_PROVIDER_ADDRESS) + assert result == mock_agent + + def test_client_agent_should_fetch_agent_by_client_address( + self, basic_job, mock_acp_client + ): + """Should fetch client agent using get_agent""" + mock_agent = MagicMock() + mock_acp_client.get_agent.return_value = mock_agent + + result = basic_job.client_agent + + mock_acp_client.get_agent.assert_called_once_with( + TEST_AGENT_ADDRESS) + assert result == mock_agent + + def test_evaluator_agent_should_fetch_agent_by_evaluator_address( + self, basic_job, mock_acp_client + ): + """Should fetch evaluator agent using get_agent""" + mock_agent = MagicMock() + mock_acp_client.get_agent.return_value = mock_agent + + result = basic_job.evaluator_agent + + mock_acp_client.get_agent.assert_called_once_with( + TEST_EVALUATOR_ADDRESS) + assert result == mock_agent + + def test_latest_memo_should_return_last_memo(self, basic_job): + """Should return the last memo in the list""" + memo1 = MagicMock(spec=ACPMemo) + memo2 = MagicMock(spec=ACPMemo) + memo3 = MagicMock(spec=ACPMemo) + + basic_job.memos = [memo1, memo2, memo3] + + assert basic_job.latest_memo == memo3 + + def test_latest_memo_should_return_none_when_no_memos(self, basic_job): + """Should return None when memos list is empty""" + basic_job.memos = [] + + assert basic_job.latest_memo is None + + class TestGetMemoById: + """Test _get_memo_by_id method""" + + def test_should_return_memo_with_matching_id(self, basic_job): + """Should return memo that matches the ID""" + memo1 = MagicMock(spec=ACPMemo) + memo1.id = "1" + + memo2 = MagicMock(spec=ACPMemo) + memo2.id = "2" + + memo3 = MagicMock(spec=ACPMemo) + memo3.id = "3" + + basic_job.memos = [memo1, memo2, memo3] + + result = basic_job._get_memo_by_id("2") + + assert result == memo2 + + def test_should_return_none_when_no_match(self, basic_job): + """Should return None when no memo matches the ID""" + memo = MagicMock(spec=ACPMemo) + memo.id = "1" + + basic_job.memos = [memo] + + result = basic_job._get_memo_by_id("999") + + assert result is None + + class TestStr: + """Test __str__ method""" + + def test_should_return_formatted_string(self, basic_job): + """Should return properly formatted job string representation""" + result = str(basic_job) + + assert "AcpJob(" in result + assert "id=123" in result + assert f"client_address='{TEST_AGENT_ADDRESS}'" in result + assert f"provider_address='{TEST_PROVIDER_ADDRESS}'" in result + assert "price=100.0" in result + assert "phase=" in result + assert "context=" in result + + class TestCreateRequirement: + """Test create_requirement method""" + + def test_should_create_memo_and_return_txn_hash( + self, basic_job, mock_acp_client + ): + """Should create MESSAGE memo with TRANSACTION phase""" + mock_operation = MagicMock(spec=OperationPayload) + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.create_memo.return_value = mock_operation + mock_contract_client.handle_operation.return_value = { + "hash": "0xabc123"} + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xabc123"): + result = basic_job.create_requirement("Test requirement") + + # Verify create_memo was called correctly + mock_contract_client.create_memo.assert_called_once_with( + job_id=123, + content="Test requirement", + memo_type=MemoType.MESSAGE, + is_secured=False, + next_phase=ACPJobPhase.TRANSACTION + ) + + # Verify operation was handled + mock_contract_client.handle_operation.assert_called_once_with([ + mock_operation]) + + assert result == "0xabc123" + + class TestAccept: + """Test accept method""" + + def test_should_sign_latest_memo_with_accept(self, basic_job): + """Should sign the latest NEGOTIATION memo with True""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.NEGOTIATION + mock_memo.sign.return_value = "0xtxhash" + + basic_job.memos = [mock_memo] + + result = basic_job.accept("Looks good") + + mock_memo.sign.assert_called_once_with( + True, + "Job 123 accepted. Looks good" + ) + assert result == "0xtxhash" + + def test_should_raise_error_when_no_negotiation_memo(self, basic_job): + """Should raise ValueError when no NEGOTIATION memo found""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.TRANSACTION + + basic_job.memos = [mock_memo] + + with pytest.raises(ValueError, match="No request memo found"): + basic_job.accept("Test") + + def test_should_raise_error_when_no_memos(self, basic_job): + """Should raise ValueError when memos list is empty""" + basic_job.memos = [] + + with pytest.raises(ValueError, match="No request memo found"): + basic_job.accept("Test") + + class TestReject: + """Test reject method""" + + def test_should_sign_latest_memo_when_in_request_phase(self, basic_job): + """Should sign memo with False when in REQUEST phase""" + basic_job.phase = ACPJobPhase.REQUEST + + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.NEGOTIATION + mock_memo.sign.return_value = "0xtxhash" + + basic_job.memos = [mock_memo] + + result = basic_job.reject("Not interested") + + mock_memo.sign.assert_called_once_with( + False, + "Job 123 rejected. Not interested" + ) + assert result == "0xtxhash" + + def test_should_create_rejected_memo_when_not_in_request_phase( + self, basic_job, mock_acp_client + ): + """Should create new REJECTED memo when not in REQUEST phase""" + basic_job.phase = ACPJobPhase.TRANSACTION + + mock_operation = MagicMock(spec=OperationPayload) + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.create_memo.return_value = mock_operation + mock_contract_client.handle_operation.return_value = { + "hash": "0xdef456"} + + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.TRANSACTION + basic_job.memos = [mock_memo] + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xdef456"): + result = basic_job.reject("Failed") + + mock_contract_client.create_memo.assert_called_once_with( + job_id=123, + content="Job 123 rejected. Failed", + memo_type=MemoType.MESSAGE, + is_secured=True, + next_phase=ACPJobPhase.REJECTED + ) + + assert result == "0xdef456" + + class TestDeliver: + """Test deliver method""" + + def test_should_create_completed_memo_with_deliverable( + self, basic_job, mock_acp_client + ): + """Should create COMPLETED memo with deliverable payload""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.EVALUATION + basic_job.memos = [mock_memo] + + mock_operation = MagicMock(spec=OperationPayload) + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.create_memo.return_value = mock_operation + mock_contract_client.handle_operation.return_value = { + "hash": "0xdelivery"} + + # DeliverablePayload is Union[str, Dict], so just use a dict + deliverable = {"result": "Task completed successfully"} + + with patch('virtuals_acp.job.prepare_payload', return_value='{"result": "Task completed successfully"}'): + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xdelivery"): + result = basic_job.deliver(deliverable) + + mock_contract_client.create_memo.assert_called_once() + assert result == "0xdelivery" + + def test_should_raise_error_when_no_evaluation_memo(self, basic_job): + """Should raise ValueError when latest memo is not EVALUATION phase""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.TRANSACTION + basic_job.memos = [mock_memo] + + # DeliverablePayload is Union[str, Dict], so just use a string + deliverable = "Test deliverable" + + with pytest.raises(ValueError, match="No transaction memo found"): + basic_job.deliver(deliverable) + + class TestEvaluate: + """Test evaluate method""" + + def test_should_sign_latest_completed_memo_with_accept(self, basic_job): + """Should sign COMPLETED memo with True when accepting""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.COMPLETED + mock_memo.sign.return_value = "0xeval" + + basic_job.memos = [mock_memo] + + result = basic_job.evaluate(True, "Great work") + + mock_memo.sign.assert_called_once_with(True, "Great work") + assert result == "0xeval" + + def test_should_use_default_reason_when_not_provided(self, basic_job): + """Should use default reason when none provided""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.COMPLETED + mock_memo.sign.return_value = "0xeval" + + basic_job.memos = [mock_memo] + + basic_job.evaluate(False) + + call_args = mock_memo.sign.call_args[0] + assert call_args[0] is False + assert "rejected" in call_args[1] + + def test_should_raise_error_when_no_completed_memo(self, basic_job): + """Should raise ValueError when no COMPLETED memo found""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.EVALUATION + basic_job.memos = [mock_memo] + + with pytest.raises(ValueError, match="No evaluation memo found"): + basic_job.evaluate(True) + + class TestCreateNotification: + """Test create_notification method""" + + def test_should_create_notification_memo(self, basic_job, mock_acp_client): + """Should create NOTIFICATION memo with COMPLETED phase""" + mock_operation = MagicMock(spec=OperationPayload) + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.create_memo.return_value = mock_operation + mock_contract_client.handle_operation.return_value = { + "hash": "0xnotif"} + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xnotif"): + result = basic_job.create_notification("Job started") + + mock_contract_client.create_memo.assert_called_once_with( + job_id=123, + content="Job started", + memo_type=MemoType.NOTIFICATION, + is_secured=True, + next_phase=ACPJobPhase.COMPLETED + ) + + assert result == "0xnotif" + + class TestCreatePayableRequirement: + """Test create_payable_requirement method""" + + def test_should_create_payable_request_with_percentage_fee( + self, basic_job, mock_acp_client + ): + """Should create PAYABLE_REQUEST with percentage fee""" + basic_job._price_type = PriceType.PERCENTAGE + basic_job._price_value = 0.05 # 5% + + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.id = 999 + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.create_payable_memo.return_value = mock_memo + mock_contract_client.handle_operation.return_value = { + "hash": "0xpayable"} + + fare = FareAmount(1000000, basic_job.base_fare) # 1 USDC + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xpayable"): + result = basic_job.create_payable_requirement( + "Payment request", + MemoType.PAYABLE_REQUEST, + fare, + "0x7777777777777777777777777777777777777777" + ) + + # Verify percentage fee was calculated (5% = 500 basis points) + call_args = mock_contract_client.create_payable_memo.call_args[1] + assert call_args['fee_amount_base_unit'] == int(0.05 * 10000) + assert call_args['fee_type'] == FeeType.PERCENTAGE_FEE + assert result == "0xpayable" + + def test_should_create_payable_transfer_escrow_with_approval( + self, basic_job, mock_acp_client + ): + """Should create PAYABLE_TRANSFER_ESCROW with token approval""" + mock_approve_op = MagicMock(spec=OperationPayload) + mock_payable_memo = MagicMock(spec=ACPMemo) + mock_payable_memo.id = 999 + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.approve_allowance.return_value = mock_approve_op + mock_contract_client.create_payable_memo.return_value = mock_payable_memo + mock_contract_client.handle_operation.return_value = { + "hash": "0xescrow"} + + fare = FareAmount(5000000, basic_job.base_fare) # 5 USDC + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xEscrow"): + result = basic_job.create_payable_requirement( + "Escrow payment", + MemoType.PAYABLE_TRANSFER_ESCROW, + fare, + "0x7777777777777777777777777777777777777777" + ) + + # Verify approval was called first + mock_contract_client.approve_allowance.assert_called_once_with( + 5000000, + fare.fare.contract_address + ) + + # Verify handle_operation was called with both operations + operations = mock_contract_client.handle_operation.call_args[0][0] + assert len(operations) == 2 + assert result == "0xEscrow" + + def test_should_use_default_expiry_when_not_provided( + self, basic_job, mock_acp_client + ): + """Should set expiry to 5 minutes from now if not provided""" + from datetime import datetime, timezone, timedelta + + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.id = 999 + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.create_payable_memo.return_value = mock_memo + mock_contract_client.handle_operation.return_value = { + "hash": "0xtx"} + + fare = FareAmount(1000000, basic_job.base_fare) + + before = datetime.now(timezone.utc) + timedelta(minutes=5) + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xtx"): + basic_job.create_payable_requirement( + "Test", + MemoType.PAYABLE_REQUEST, + fare, + "0x7777777777777777777777777777777777777777" + # Note: no expired_at provided + ) + + after = datetime.now(timezone.utc) + timedelta(minutes=5) + + # Verify expired_at is around 5 minutes from now + call_args = mock_contract_client.create_payable_memo.call_args[1] + expired_at = call_args['expired_at'] + assert before <= expired_at <= after + + class TestPayAndAcceptRequirement: + """Test pay_and_accept_requirement method""" + + def test_should_approve_and_sign_memo(self, basic_job, mock_acp_client): + """Should approve allowance and sign memo""" + # Setup transaction memo + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.id = 999 + mock_memo.next_phase = ACPJobPhase.TRANSACTION + mock_memo.payable_details = None + basic_job.memos = [mock_memo] + + mock_approve_op = MagicMock(spec=OperationPayload) + mock_sign_op = MagicMock(spec=OperationPayload) + mock_create_op = MagicMock(spec=OperationPayload) + + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.approve_allowance.return_value = mock_approve_op + mock_contract_client.sign_memo.return_value = mock_sign_op + mock_contract_client.create_memo.return_value = mock_create_op + mock_contract_client.handle_operation.return_value = { + "hash": "0xpay"} + + # Mock x402 check + mock_x402_details = MagicMock() + mock_x402_details.is_x402 = False + mock_contract_client.get_x402_payment_details.return_value = mock_x402_details + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xpay"): + result = basic_job.pay_and_accept_requirement("Payment made") + + # Verify approval, sign, and memo creation + assert mock_contract_client.approve_allowance.called + assert mock_contract_client.sign_memo.called + assert mock_contract_client.create_memo.called + assert result == "0xpay" + + def test_should_handle_payable_details_with_different_token( + self, basic_job, mock_acp_client + ): + """Should approve both tokens when payable uses different token""" + # Setup transaction memo with payable details in different token + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.id = 999 + mock_memo.next_phase = ACPJobPhase.TRANSACTION + mock_memo.payable_details = { + "amount": "2000000", # 2 USDC + "token": "0x9999999999999999999999999999999999999999" # Different token + } + basic_job.memos = [mock_memo] + + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.approve_allowance.return_value = MagicMock() + mock_contract_client.sign_memo.return_value = MagicMock() + mock_contract_client.create_memo.return_value = MagicMock() + mock_contract_client.handle_operation.return_value = { + "hash": "0xpay"} + + mock_x402_details = MagicMock() + mock_x402_details.is_x402 = False + mock_contract_client.get_x402_payment_details.return_value = mock_x402_details + + # Mock FareAmountBase.from_contract_address to return proper value + mock_transfer_amount = MagicMock() + mock_transfer_amount.amount = 2000000 + mock_transfer_fare = Fare( + contract_address="0x9999999999999999999999999999999999999999", decimals=6) + mock_transfer_amount.fare = mock_transfer_fare + + with patch('virtuals_acp.job.FareAmountBase.from_contract_address', return_value=mock_transfer_amount): + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xpay"): + basic_job.pay_and_accept_requirement() + + # Verify two approvals (base fare + transfer amount) + assert mock_contract_client.approve_allowance.call_count == 2 + + def test_should_perform_x402_payment_when_is_x402_job( + self, basic_job, mock_acp_client, complete_x402_response + ): + """Should call perform_x402_payment when job is x402""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.id = 999 + mock_memo.next_phase = ACPJobPhase.TRANSACTION + mock_memo.payable_details = None + basic_job.memos = [mock_memo] + + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.approve_allowance.return_value = MagicMock() + mock_contract_client.sign_memo.return_value = MagicMock() + mock_contract_client.create_memo.return_value = MagicMock() + mock_contract_client.handle_operation.return_value = { + "hash": "0xpay"} + + # Mock x402 job - setup all dependencies + mock_x402_details = MagicMock() + mock_x402_details.is_x402 = True + mock_contract_client.get_x402_payment_details.return_value = mock_x402_details + + # Mock x402 payment flow + mock_contract_client.get_acp_version.return_value = "1.0.0" + mock_contract_client.perform_x402_request.return_value = complete_x402_response + + mock_x402_payment = MagicMock() + mock_x402_payment.encodedPayment = "0xencodedpayment" + mock_x402_payment.signature = "0xsignature" + mock_x402_payment.message = { + "from": "0xfrom", + "to": "0xto", + "value": "1000000", + "validAfter": 0, + "validBefore": 9999999999, + "nonce": "123456" + } + mock_contract_client.generate_x402_payment.return_value = mock_x402_payment + mock_contract_client.submit_transfer_with_authorization.return_value = [ + MagicMock()] + + # Mock polling - budget received immediately + mock_payment_details = MagicMock() + mock_payment_details.is_budget_received = True + mock_contract_client.get_x402_payment_details.return_value = mock_payment_details + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xpay"): + with patch('time.sleep'): # Don't actually sleep + basic_job.pay_and_accept_requirement() + + # Verify x402 methods were called + assert mock_contract_client.perform_x402_request.called + assert mock_contract_client.generate_x402_payment.called + + def test_should_raise_error_when_no_transaction_memo(self, basic_job): + """Should raise exception when no TRANSACTION memo found""" + basic_job.memos = [] + + with pytest.raises(Exception, match="No negotiation memo found"): + basic_job.pay_and_accept_requirement() + + class TestRejectPayable: + """Test reject_payable method""" + + def test_should_create_payable_rejection_with_refund( + self, basic_job, mock_acp_client + ): + """Should create PAYABLE_TRANSFER for refund with NO_FEE""" + mock_approve_op = MagicMock(spec=OperationPayload) + mock_payable_op = MagicMock(spec=OperationPayload) + + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.approve_allowance.return_value = mock_approve_op + mock_contract_client.create_payable_memo.return_value = mock_payable_op + mock_contract_client.handle_operation.return_value = { + "hash": "0xrefund"} + + fare = FareAmount(3000000, basic_job.base_fare) + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xrefund"): + result = basic_job.reject_payable( + "Rejecting with refund", + fare + ) + + # Verify approval and payable memo creation + mock_contract_client.approve_allowance.assert_called_once() + + call_args = mock_contract_client.create_payable_memo.call_args[1] + assert call_args['next_phase'] == ACPJobPhase.REJECTED + assert call_args['memo_type'] == MemoType.PAYABLE_TRANSFER + assert call_args['fee_type'] == FeeType.NO_FEE + assert call_args['recipient'] == basic_job.client_address + assert result == "0xrefund" + + class TestDeliverPayable: + """Test deliver_payable method""" + + def test_should_create_payable_delivery_with_percentage_fee( + self, basic_job, mock_acp_client + ): + """Should create PAYABLE_TRANSFER with percentage fee when applicable""" + basic_job._price_type = PriceType.PERCENTAGE + basic_job._price_value = 0.10 # 10% + + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.EVALUATION + basic_job.memos = [mock_memo] + + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.approve_allowance.return_value = MagicMock() + mock_contract_client.create_payable_memo.return_value = MagicMock() + mock_contract_client.handle_operation.return_value = { + "hash": "0xdeliver"} + + fare = FareAmount(10000000, basic_job.base_fare) + + with patch('virtuals_acp.job.prepare_payload', return_value='{"result": "done"}'): + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xdeliver"): + result = basic_job.deliver_payable( + {"result": "done"}, + fare + ) + + # Verify percentage fee (10% = 1000 basis points) + call_args = mock_contract_client.create_payable_memo.call_args[1] + assert call_args['fee_amount_base_unit'] == int(0.10 * 10000) + assert call_args['fee_type'] == FeeType.PERCENTAGE_FEE + assert result == "0xdeliver" + + def test_should_skip_fee_when_requested(self, basic_job, mock_acp_client): + """Should use NO_FEE when skip_fee is True""" + basic_job._price_type = PriceType.PERCENTAGE + basic_job._price_value = 0.10 + + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.EVALUATION + basic_job.memos = [mock_memo] + + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.approve_allowance.return_value = MagicMock() + mock_contract_client.create_payable_memo.return_value = MagicMock() + mock_contract_client.handle_operation.return_value = { + "hash": "0xdeliver"} + + fare = FareAmount(10000000, basic_job.base_fare) + + with patch('virtuals_acp.job.prepare_payload', return_value='{}'): + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xdeliver"): + basic_job.deliver_payable( + {}, + fare, + skip_fee=True + ) + + # Verify NO_FEE was used + call_args = mock_contract_client.create_payable_memo.call_args[1] + assert call_args['fee_type'] == FeeType.NO_FEE + + def test_should_raise_error_when_no_evaluation_memo(self, basic_job): + """Should raise ValueError when not in EVALUATION phase""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.TRANSACTION + basic_job.memos = [mock_memo] + + fare = FareAmount(1000000, basic_job.base_fare) + + with pytest.raises(ValueError, match="No transaction memo found"): + basic_job.deliver_payable({}, fare) + + class TestCreatePayableNotification: + """Test create_payable_notification method""" + + def test_should_create_payable_notification_with_fee( + self, basic_job, mock_acp_client + ): + """Should create PAYABLE_NOTIFICATION with appropriate fee""" + basic_job._price_type = PriceType.PERCENTAGE + basic_job._price_value = 0.03 + + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.approve_allowance.return_value = MagicMock() + mock_contract_client.create_payable_memo.return_value = MagicMock() + mock_contract_client.handle_operation.return_value = { + "hash": "0xnotif"} + + fare = FareAmount(2000000, basic_job.base_fare) + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xnotif"): + result = basic_job.create_payable_notification( + "Milestone reached", + fare + ) + + call_args = mock_contract_client.create_payable_memo.call_args[1] + assert call_args['memo_type'] == MemoType.PAYABLE_NOTIFICATION + assert call_args['next_phase'] == ACPJobPhase.COMPLETED + assert result == "0xnotif" + + class TestPerformX402Payment: + """Test perform_x402_payment method""" + + def test_should_skip_when_payment_not_required( + self, basic_job, mock_acp_client + ): + """Should return early when isPaymentRequired is False""" + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.perform_x402_request.return_value = { + "isPaymentRequired": False + } + mock_contract_client.get_acp_version.return_value = "1.0.0" + + # Should complete without error + basic_job.perform_x402_payment(100.0) + + # Verify only one API call was made + assert mock_contract_client.perform_x402_request.call_count == 1 + + def test_should_perform_x402_payment_flow( + self, basic_job, mock_acp_client, complete_x402_response + ): + """Should perform full x402 payment flow""" + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.get_acp_version.return_value = "1.0.0" + + # First request returns payment required + mock_contract_client.perform_x402_request.side_effect = [ + complete_x402_response, + { + "isPaymentRequired": True # Still requires payment after auth + } + ] + + # Mock x402 payment generation + mock_x402_payment = MagicMock() + mock_x402_payment.encodedPayment = "0xencodedpayment" + mock_x402_payment.signature = "0xsignature" + mock_x402_payment.message = { + "from": "0xfrom", + "to": "0xto", + "value": "1000000", + "validAfter": 0, + "validBefore": 9999999999, + "nonce": "123456" + } + mock_contract_client.generate_x402_payment.return_value = mock_x402_payment + + # Mock operations + mock_contract_client.submit_transfer_with_authorization.return_value = [ + MagicMock()] + mock_contract_client.handle_operation.return_value = { + "hash": "0xtx"} + + # Mock polling - budget received on first check + mock_payment_details = MagicMock() + mock_payment_details.is_budget_received = True + mock_contract_client.get_x402_payment_details.return_value = mock_payment_details + + with patch('time.sleep'): # Don't actually sleep + basic_job.perform_x402_payment(100.0) + + # Verify nonce was updated + mock_contract_client.update_job_x402_nonce.assert_called_once_with( + basic_job.id, + "123456" + ) + + # Verify transfer was submitted + mock_contract_client.submit_transfer_with_authorization.assert_called_once() + mock_contract_client.handle_operation.assert_called_once() + + def test_should_poll_until_budget_received( + self, basic_job, mock_acp_client, complete_x402_response + ): + """Should poll multiple times until budget is received""" + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.get_acp_version.return_value = "1.0.0" + + mock_contract_client.perform_x402_request.side_effect = [ + complete_x402_response, + {"isPaymentRequired": False} # Payment successful + ] + + mock_x402_payment = MagicMock() + mock_x402_payment.encodedPayment = "0xencodedpayment" + mock_x402_payment.signature = "0xsignature" + mock_x402_payment.message = {"nonce": "123456"} + mock_contract_client.generate_x402_payment.return_value = mock_x402_payment + + # Mock polling - not received, not received, then received + mock_payment_details_not_received = MagicMock() + mock_payment_details_not_received.is_budget_received = False + + mock_payment_details_received = MagicMock() + mock_payment_details_received.is_budget_received = True + + mock_contract_client.get_x402_payment_details.side_effect = [ + mock_payment_details_not_received, + mock_payment_details_not_received, + mock_payment_details_received + ] + + with patch('time.sleep') as mock_sleep: + basic_job.perform_x402_payment(100.0) + + # Verify polling happened multiple times + assert mock_contract_client.get_x402_payment_details.call_count == 3 + # Verify sleep was called (exponential backoff) + assert mock_sleep.call_count == 2 + + def test_should_timeout_after_max_iterations( + self, basic_job, mock_acp_client, complete_x402_response + ): + """Should raise exception after max polling iterations""" + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.get_acp_version.return_value = "1.0.0" + + mock_contract_client.perform_x402_request.side_effect = [ + complete_x402_response, + {"isPaymentRequired": False} + ] + + mock_x402_payment = MagicMock() + mock_x402_payment.encodedPayment = "0xencodedpayment" + mock_x402_payment.signature = "0xsignature" + mock_x402_payment.message = {"nonce": "123456"} + mock_contract_client.generate_x402_payment.return_value = mock_x402_payment + + # Mock polling - never receives budget + mock_payment_details = MagicMock() + mock_payment_details.is_budget_received = False + mock_contract_client.get_x402_payment_details.return_value = mock_payment_details + + with patch('time.sleep'): + with pytest.raises(Exception, match="X402 payment timed out"): + basic_job.perform_x402_payment(100.0) + + def test_should_raise_error_when_no_accepts( + self, basic_job, mock_acp_client + ): + """Should raise exception when no payment requirements""" + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.get_acp_version.return_value = "1.0.0" + + mock_contract_client.perform_x402_request.return_value = { + "isPaymentRequired": True, + "data": { + "accepts": [] # No payment methods + } + } + + with pytest.raises(Exception, match="No X402 payment requirements found"): + basic_job.perform_x402_payment(100.0) + + def test_should_raise_error_when_no_nonce( + self, basic_job, mock_acp_client, complete_x402_response + ): + """Should raise exception when nonce is missing""" + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.get_acp_version.return_value = "1.0.0" + + mock_contract_client.perform_x402_request.return_value = complete_x402_response + + mock_x402_payment = MagicMock() + mock_x402_payment.encodedPayment = "0xencodedpayment" + mock_x402_payment.signature = "0xsignature" + mock_x402_payment.message = None # No message/nonce + mock_contract_client.generate_x402_payment.return_value = mock_x402_payment + + with pytest.raises(Exception, match="No nonce found in X402 message"): + basic_job.perform_x402_payment(100.0) + + class TestRespond: + """Test respond method""" + + def test_should_accept_and_create_requirement_when_true( + self, basic_job, mock_acp_client + ): + """Should call accept and create_requirement when accept=True""" + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.NEGOTIATION + mock_memo.sign.return_value = "0xaccept" + basic_job.memos = [mock_memo] + + # Mock the contract client for create_requirement + mock_operation = MagicMock(spec=OperationPayload) + mock_contract_client = mock_acp_client.contract_client_by_address.return_value + mock_contract_client.create_memo.return_value = mock_operation + mock_contract_client.handle_operation.return_value = { + "hash": "0xreq"} + + with patch('virtuals_acp.job.get_txn_hash_from_response', return_value="0xreq"): + result = basic_job.respond(True, "Good") + + # Verify memo.sign was called (from accept) + # Note: respond() creates "Job 123 accepted. Good" and passes it to accept() + # which prepends "Job 123 accepted." again + mock_memo.sign.assert_called_once_with( + True, "Job 123 accepted. Job 123 accepted. Good") + + # Verify create_memo was called (from create_requirement) + mock_contract_client.create_memo.assert_called_once() + + assert result == "0xreq" + + def test_should_reject_when_false(self, basic_job): + """Should call reject when accept=False""" + basic_job.phase = ACPJobPhase.REQUEST + mock_memo = MagicMock(spec=ACPMemo) + mock_memo.next_phase = ACPJobPhase.NEGOTIATION + mock_memo.sign.return_value = "0xreject" + basic_job.memos = [mock_memo] + + result = basic_job.respond(False, "Not interested") + + # Verify memo.sign was called with False + # Note: respond() creates "Job 123 rejected. Not interested" and passes it to reject() + # which prepends "Job 123 rejected." again + mock_memo.sign.assert_called_once_with( + False, "Job 123 rejected. Job 123 rejected. Not interested") + assert result == "0xreject"