diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 8e921ad..82082ec 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -13,8 +13,12 @@ permissions: jobs: lint-and-test: - uses: esclient/tools/.github/workflows/lint-and-test-python.yml@v1.0.4 + uses: esclient/tools/.github/workflows/lint-and-test-python.yml@v1.0.1 with: - python-version: '3.13.7' + python-version: "3.13.7" + source: "modservice" + sonar-inclusions: "src/**,Dockerfile" + sonar-exclusions: "**/grpc/**" + sonar-coverage-exclusions: "src/modservice/server.py,src/modservice/settings.py,src/modservice/s3_client.py" secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/justfile b/justfile index 45dc9a2..5d25313 100644 --- a/justfile +++ b/justfile @@ -7,8 +7,8 @@ LOAD_ENVS_URL := 'https://raw.githubusercontent.com/esclient/tools/refs/heads/ma PROTO_TAG := 'v0.1.2' PROTO_NAME := 'mod.proto' TMP_DIR := '.proto' -OUT_DIR := 'src/modservice/grpc' -SERVICE_NAME := 'mod' +SOURCE := 'modservice' +OUT_DIR := 'src/' + SOURCE + '/grpc' MKDIR_TOOLS := 'mkdir -p tools' diff --git a/pdm.lock b/pdm.lock index c1d0bdf..7f0e05d 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:9c6811477721ca01ee069bd464fac3c465b39238249febefbe3900192ca549b6" +content_hash = "sha256:7d4b4cbaad3dcb3661f2c47f4e53b29584711390ec6ef016f1b925bf24f9a2a6" [[metadata.targets]] requires_python = "~=3.13" @@ -219,7 +219,7 @@ name = "asyncpg" version = "0.30.0" requires_python = ">=3.8.0" summary = "An asyncio PostgreSQL driver" -groups = ["default"] +groups = ["default", "dev"] dependencies = [ "async-timeout>=4.0.3; python_version < \"3.11.0\"", ] @@ -235,6 +235,21 @@ files = [ {file = "asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851"}, ] +[[package]] +name = "asyncpg-stubs" +version = "0.30.2" +requires_python = "<4.0,>=3.8" +summary = "asyncpg stubs" +groups = ["dev"] +dependencies = [ + "asyncpg<0.31,>=0.30", + "typing-extensions<5.0.0,>=4.13.0", +] +files = [ + {file = "asyncpg_stubs-0.30.2-py3-none-any.whl", hash = "sha256:e57818bbaf10945a60ff3219da3c5ce97e1b424503b6a6f0a18db99797397cbb"}, + {file = "asyncpg_stubs-0.30.2.tar.gz", hash = "sha256:b8a1b7cb790a7b8a0e4e64e438a97c3fac77ea02441b563b1975748f18af33ab"}, +] + [[package]] name = "attrs" version = "25.4.0" @@ -304,6 +319,20 @@ files = [ {file = "botocore-1.40.18.tar.gz", hash = "sha256:afd69bdadd8c55cc89d69de0799829e555193a352d87867f746e19020271cc0f"}, ] +[[package]] +name = "botocore-stubs" +version = "1.40.55" +requires_python = ">=3.9" +summary = "Type annotations and code completion for botocore" +groups = ["dev"] +dependencies = [ + "types-awscrt", +] +files = [ + {file = "botocore_stubs-1.40.55-py3-none-any.whl", hash = "sha256:fdc85df8960a6f156c57c5980d125c7467134ca8d612f32175cb88a49a0a6cf5"}, + {file = "botocore_stubs-1.40.55.tar.gz", hash = "sha256:57c8978b0bbe40a9fa29fde564de8a04679a223f430a97d03ada62ec112231af"}, +] + [[package]] name = "click" version = "8.2.1" @@ -330,6 +359,151 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.11.0" +requires_python = ">=3.10" +summary = "Code coverage measurement for Python" +groups = ["dev"] +files = [ + {file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"}, + {file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"}, + {file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"}, + {file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"}, + {file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"}, + {file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"}, + {file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"}, + {file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"}, + {file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"}, + {file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"}, + {file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"}, + {file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"}, + {file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"}, + {file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"}, + {file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"}, + {file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"}, +] + +[[package]] +name = "coverage" +version = "7.11.0" +extras = ["toml"] +requires_python = ">=3.10" +summary = "Code coverage measurement for Python" +groups = ["dev"] +dependencies = [ + "coverage==7.11.0", + "tomli; python_full_version <= \"3.11.0a6\"", +] +files = [ + {file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"}, + {file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"}, + {file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"}, + {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"}, + {file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"}, + {file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"}, + {file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"}, + {file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"}, + {file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"}, + {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"}, + {file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"}, + {file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"}, + {file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"}, + {file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"}, + {file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"}, + {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"}, + {file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"}, + {file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"}, + {file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"}, + {file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"}, + {file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"}, + {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"}, + {file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"}, + {file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"}, + {file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"}, + {file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"}, + {file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"}, +] + +[[package]] +name = "faker" +version = "37.11.0" +requires_python = ">=3.9" +summary = "Faker is a Python package that generates fake data for you." +groups = ["dev"] +dependencies = [ + "tzdata", +] +files = [ + {file = "faker-37.11.0-py3-none-any.whl", hash = "sha256:1508d2da94dfd1e0087b36f386126d84f8583b3de19ac18e392a2831a6676c57"}, + {file = "faker-37.11.0.tar.gz", hash = "sha256:22969803849ba0618be8eee2dd01d0d9e2cd3b75e6ff1a291fa9abcdb34da5e6"}, +] + [[package]] name = "flake8" version = "7.3.0" @@ -1020,6 +1194,47 @@ files = [ {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +requires_python = ">=3.8" +summary = "Pytest support for asyncio" +groups = ["dev"] +dependencies = [ + "pytest<9,>=8.2", +] +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]] +name = "pytest-cov" +version = "5.0.0" +requires_python = ">=3.8" +summary = "Pytest plugin for measuring coverage." +groups = ["dev"] +dependencies = [ + "coverage[toml]>=5.2.1", + "pytest>=4.6", +] +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[[package]] +name = "pytest-faker" +version = "2.0.0" +summary = "Faker integration with the pytest framework." +groups = ["dev"] +dependencies = [ + "Faker>=0.7.3", +] +files = [ + {file = "pytest-faker-2.0.0.tar.gz", hash = "sha256:6b37bb89d94f96552bfa51f8e8b89d32addded8ddb58a331488299ef0137d9b6"}, +] + [[package]] name = "pytest-mock" version = "3.15.1" @@ -1145,6 +1360,60 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "types-aioboto3" +version = "15.4.0" +requires_python = ">=3.8" +summary = "Type annotations for aioboto3 15.4.0 generated with mypy-boto3-builder 8.11.0" +groups = ["dev"] +dependencies = [ + "botocore-stubs", + "types-aiobotocore", + "types-s3transfer", + "typing-extensions>=4.1.0; python_version < \"3.12\"", +] +files = [ + {file = "types_aioboto3-15.4.0-py3-none-any.whl", hash = "sha256:ec50618b173a96ea410210b35cd4f4f7bb71b11c3d3a9417506b71c8950ebeff"}, + {file = "types_aioboto3-15.4.0.tar.gz", hash = "sha256:33c45c97abdbb43f2df0b162a19ab83e3d13ee34caf54773befd95fa3799b411"}, +] + +[[package]] +name = "types-aiobotocore" +version = "2.25.0" +requires_python = ">=3.8" +summary = "Type annotations for aiobotocore 2.25.0 generated with mypy-boto3-builder 8.11.0" +groups = ["dev"] +dependencies = [ + "botocore-stubs", + "typing-extensions>=4.1.0; python_version < \"3.12\"", +] +files = [ + {file = "types_aiobotocore-2.25.0-py3-none-any.whl", hash = "sha256:7a9efa7e8240774546b95ae0db3b6f7cbf9f05c89db317612ec1d678649a88ff"}, + {file = "types_aiobotocore-2.25.0.tar.gz", hash = "sha256:7e5e96568935d5255095b5f8aaedc0c1b265770a260a2ab6ed7d4c7ea7fe8228"}, +] + +[[package]] +name = "types-aiofiles" +version = "25.1.0.20251011" +requires_python = ">=3.9" +summary = "Typing stubs for aiofiles" +groups = ["dev"] +files = [ + {file = "types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c"}, + {file = "types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff"}, +] + +[[package]] +name = "types-awscrt" +version = "0.28.2" +requires_python = ">=3.8" +summary = "Type annotations and code completion for awscrt" +groups = ["dev"] +files = [ + {file = "types_awscrt-0.28.2-py3-none-any.whl", hash = "sha256:d08916fa735cfc032e6a8cfdac92785f1c4e88623999b224ea4e6267d5de5fcb"}, + {file = "types_awscrt-0.28.2.tar.gz", hash = "sha256:4349b6fc7b1cd9c9eb782701fb213875db89ab1781219c0e947dd7c4d9dcd65e"}, +] + [[package]] name = "types-protobuf" version = "6.32.1.20250918" @@ -1167,6 +1436,17 @@ files = [ {file = "types_psycopg2-2.9.21.20250915.tar.gz", hash = "sha256:bfeb8f54c32490e7b5edc46215ab4163693192bc90407b4a023822de9239f5c8"}, ] +[[package]] +name = "types-s3transfer" +version = "0.14.0" +requires_python = ">=3.8" +summary = "Type annotations and code completion for s3transfer" +groups = ["dev"] +files = [ + {file = "types_s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:108134854069a38b048e9b710b9b35904d22a9d0f37e4e1889c2e6b58e5b3253"}, + {file = "types_s3transfer-0.14.0.tar.gz", hash = "sha256:17f800a87c7eafab0434e9d87452c809c290ae906c2024c24261c564479e9c95"}, +] + [[package]] name = "typing-extensions" version = "4.14.1" @@ -1192,6 +1472,17 @@ files = [ {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, ] +[[package]] +name = "tzdata" +version = "2025.2" +requires_python = ">=2" +summary = "Provider of IANA time zone data" +groups = ["dev"] +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + [[package]] name = "urllib3" version = "2.5.0" diff --git a/pyproject.toml b/pyproject.toml index 8a8497e..08d999a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,24 @@ explicit_package_bases = true mypy_path = ["src"] plugins = ["pydantic.mypy"] +[tool.pytest.ini_options] +addopts = "--import-mode=importlib" +asyncio_mode = "strict" +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.coverage.run] +source = ["src/modservice"] +relative_files = true + +[tool.coverage.report] +include = [ + "src/modservice/handler/*", + "src/modservice/service/*", + "src/modservice/repository/*", +] +omit = ["src/modservice/server.py"] + [project] name = "modservice" version = "0.0.1" @@ -121,7 +139,11 @@ dev = [ "grpc-stubs==1.53.0.6", "protobuf==6.32.1", "pytest==8.4.2", + "pytest-asyncio==0.24.0", + "pytest-cov==5.0.0", "pytest-mock==3.15.1", + "pytest-faker==2.0.0", + "Faker==37.11.0", "black==25.9.0", "isort==6.0.1", "flake8==7.3.0", @@ -130,4 +152,8 @@ dev = [ "mypy==1.18.2", "types-protobuf==6.32.1.20250918", "types-psycopg2==2.9.21.20250915", + "types-aioboto3==15.4.0", + "types-aiofiles==25.1.0.20251011", + "botocore-stubs==1.40.55", + "asyncpg-stubs==0.30.2", ] diff --git a/src/modservice/repository/create_mod.py b/src/modservice/repository/create_mod.py index 1312306..b7e7368 100644 --- a/src/modservice/repository/create_mod.py +++ b/src/modservice/repository/create_mod.py @@ -1,4 +1,4 @@ -from asyncpg import Pool # type: ignore[import-untyped] +from asyncpg import Pool async def create_mod( diff --git a/src/modservice/repository/get_mod_s3_key.py b/src/modservice/repository/get_mod_s3_key.py index 0a27e55..e0ae8a4 100644 --- a/src/modservice/repository/get_mod_s3_key.py +++ b/src/modservice/repository/get_mod_s3_key.py @@ -1,7 +1,9 @@ -from asyncpg import Pool # type: ignore[import-untyped] +from typing import Literal +from asyncpg import Pool -async def get_mod_s3_key(db_pool: Pool, id: int) -> int: + +async def get_mod_s3_key(db_pool: Pool, id: int) -> str | Literal[0]: async with db_pool.acquire() as conn: s3_key = await conn.fetchval( """ @@ -12,4 +14,6 @@ async def get_mod_s3_key(db_pool: Pool, id: int) -> int: """, id, ) - return s3_key if s3_key else 0 + if s3_key is None: + return 0 + return str(s3_key) diff --git a/src/modservice/repository/get_mods.py b/src/modservice/repository/get_mods.py index c504bbc..5bdc585 100644 --- a/src/modservice/repository/get_mods.py +++ b/src/modservice/repository/get_mods.py @@ -1,6 +1,6 @@ from typing import Any -from asyncpg import Pool # type: ignore[import-untyped] +from asyncpg import Pool async def get_mods( diff --git a/src/modservice/repository/insert_s3_key.py b/src/modservice/repository/insert_s3_key.py index b1e5d59..30d955d 100644 --- a/src/modservice/repository/insert_s3_key.py +++ b/src/modservice/repository/insert_s3_key.py @@ -1,4 +1,4 @@ -from asyncpg import Pool # type: ignore[import-untyped] +from asyncpg import Pool def generate_s3_key(author_id: int, mod_id: int) -> str: diff --git a/src/modservice/repository/repository.py b/src/modservice/repository/repository.py index 67d0f46..2750f4b 100644 --- a/src/modservice/repository/repository.py +++ b/src/modservice/repository/repository.py @@ -1,6 +1,6 @@ from typing import Any -from asyncpg import Pool # type: ignore[import-untyped] +from asyncpg import Pool from modservice.repository.create_mod import create_mod as _create_mod from modservice.repository.get_mod_s3_key import ( diff --git a/src/modservice/repository/set_status.py b/src/modservice/repository/set_status.py index 9127a1c..3e01070 100644 --- a/src/modservice/repository/set_status.py +++ b/src/modservice/repository/set_status.py @@ -1,4 +1,4 @@ -from asyncpg import Pool # type: ignore[import-untyped] +from asyncpg import Pool async def set_status(db_pool: Pool, mod_id: int, status: str) -> bool: diff --git a/src/modservice/s3_client.py b/src/modservice/s3_client.py index cf97519..7b25d58 100644 --- a/src/modservice/s3_client.py +++ b/src/modservice/s3_client.py @@ -2,7 +2,8 @@ from typing import Any import aioboto3 -from botocore.config import Config +import aiofiles +from aiobotocore.config import AioConfig logger = logging.getLogger(__name__) @@ -22,7 +23,7 @@ def __init__( self.bucket_name = bucket_name self.ssl_verify = verify - self.config = Config( + self.config = AioConfig( signature_version="s3v4", s3={"addressing_style": "virtual"}, region_name="ru-central-1", @@ -51,11 +52,14 @@ async def upload_file(self, file_path: str, s3_key: str) -> bool: try: logger.info(f"Загружаем файл {file_path} как {s3_key}") - async with self.get_client() as client: - with open(file_path, "rb") as file: - await client.put_object( - Bucket=self.bucket_name, Key=s3_key, Body=file.read() - ) + async with ( + self.get_client() as client, + aiofiles.open(file_path, "rb") as file, + ): + payload = await file.read() + await client.put_object( + Bucket=self.bucket_name, Key=s3_key, Body=payload + ) logger.info(f"Файл успешно загружен: {s3_key}") return True @@ -79,8 +83,8 @@ async def download_file(self, s3_key: str, local_path: str) -> bool: async with response["Body"] as stream: content = await stream.read() - with open(local_path, "wb") as file: - file.write(content) + async with aiofiles.open(local_path, "wb") as file: + await file.write(content) return True @@ -88,7 +92,7 @@ async def download_file(self, s3_key: str, local_path: str) -> bool: logger.error(f"Ошибка при скачивании файла {s3_key}: {e!s}") return False - def time_format(self, seconds: int) -> str: + def time_format(self, seconds: int | None) -> str: if seconds is not None: seconds = int(seconds) d = seconds // (3600 * 24) diff --git a/tests/handler/test_create_mod.py b/tests/handler/test_create_mod.py index 85d66d0..c69aae5 100644 --- a/tests/handler/test_create_mod.py +++ b/tests/handler/test_create_mod.py @@ -1,5 +1,38 @@ +from unittest.mock import AsyncMock + +import grpc +import pytest +from faker import Faker from pytest_mock import MockerFixture +from modservice.grpc import mod_pb2 +from modservice.handler.create_mod import CreateMod +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_create_mod_returns_response( + mocker: MockerFixture, faker: Faker +) -> None: + context = mocker.Mock(spec=grpc.ServicerContext) + service = mocker.Mock(spec=ModService) + mod_id = faker.random_int(min=1, max=100000) + s3_key = f"{faker.random_int(min=1, max=100000)}/{mod_id}" + upload_url = faker.uri() + service.create_mod = AsyncMock(return_value=(mod_id, s3_key, upload_url)) + + request = mod_pb2.CreateModRequest( + title=faker.sentence(nb_words=4), + author_id=faker.random_int(min=1, max=100000), + description=faker.text(), + ) + + response = await CreateMod(service, request, context) -def test_create_mod_success(mocker: MockerFixture) -> None: - assert True + assert isinstance(response, mod_pb2.CreateModResponse) + assert response.mod_id == mod_id + assert response.s3_key == s3_key + assert response.upload_url == upload_url + service.create_mod.assert_awaited_once_with( + request.title, request.author_id, request.description + ) diff --git a/tests/handler/test_get_download_link.py b/tests/handler/test_get_download_link.py new file mode 100644 index 0000000..f1b5da8 --- /dev/null +++ b/tests/handler/test_get_download_link.py @@ -0,0 +1,28 @@ +from unittest.mock import AsyncMock + +import grpc +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.grpc import mod_pb2 +from modservice.handler.get_mod_download_link import GetDownloadLink +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_get_download_link_returns_url( + mocker: MockerFixture, faker: Faker +) -> None: + context = mocker.Mock(spec=grpc.ServicerContext) + service = mocker.Mock(spec=ModService) + url = faker.uri() + service.get_mod_download_link = AsyncMock(return_value=url) + + mod_id = faker.random_int(min=1, max=100000) + request = mod_pb2.GetModDownloadLinkRequest(mod_id=mod_id) + response = await GetDownloadLink(service, request, context) + + assert isinstance(response, mod_pb2.GetModDownloadLinkResponse) + assert response.link_url == url + service.get_mod_download_link.assert_awaited_once_with(mod_id) diff --git a/tests/handler/test_get_mods.py b/tests/handler/test_get_mods.py new file mode 100644 index 0000000..d925141 --- /dev/null +++ b/tests/handler/test_get_mods.py @@ -0,0 +1,75 @@ +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import grpc +import pytest +from faker import Faker +from google.protobuf.timestamp_pb2 import Timestamp +from pytest_mock import MockerFixture + +from modservice.constants import STATUS_BANNED, STATUS_UPLOADED +from modservice.grpc import mod_pb2 +from modservice.handler.get_mods import GetMods +from modservice.service.service import ModService + + +def _ts_from_datetime(dt: datetime) -> Timestamp: + ts = Timestamp() + ts.FromDatetime(dt) + return ts + + +@pytest.mark.asyncio +async def test_get_mods_converts_models( + mocker: MockerFixture, faker: Faker +) -> None: + context = mocker.Mock(spec=grpc.ServicerContext) + service = mocker.Mock(spec=ModService) + + created_at_first = faker.date_time(tzinfo=UTC) + created_at_second = faker.date_time(tzinfo=UTC) + + mods_data = [ + { + "id": faker.random_int(min=1, max=100000), + "author_id": faker.random_int(min=1, max=100000), + "title": faker.sentence(nb_words=3), + "description": faker.text(), + "version": faker.random_int(min=1, max=10), + "s3_key": faker.file_path(depth=2), + "status": STATUS_UPLOADED, + "created_at": _ts_from_datetime(created_at_first), + }, + { + "id": faker.random_int(min=1, max=100000), + "author_id": faker.random_int(min=1, max=100000), + "title": faker.sentence(nb_words=4), + "description": faker.text(), + "version": faker.random_int(min=1, max=10), + "s3_key": faker.file_path(depth=2), + "status": STATUS_BANNED, + "created_at": _ts_from_datetime(created_at_second), + }, + ] + service.get_mods = AsyncMock(return_value=mods_data) + + request = mod_pb2.GetModsRequest() + response = await GetMods(service, request, context) + + assert isinstance(response, mod_pb2.GetModsResponse) + assert len(response.mods) == 2 + + first_mod = response.mods[0] + assert first_mod.id == mods_data[0]["id"] + assert first_mod.author_id == mods_data[0]["author_id"] + assert first_mod.title == mods_data[0]["title"] + assert first_mod.description == mods_data[0]["description"] + assert first_mod.version == mods_data[0]["version"] + assert first_mod.status == mod_pb2.ModStatus.MOD_STATUS_UPLOADED + assert first_mod.created_at == mods_data[0]["created_at"] + + second_mod = response.mods[1] + assert second_mod.status == mod_pb2.ModStatus.MOD_STATUS_BANNED + assert second_mod.created_at == mods_data[1]["created_at"] + + service.get_mods.assert_awaited_once_with() diff --git a/tests/handler/test_handler.py b/tests/handler/test_handler.py new file mode 100644 index 0000000..4a1b3ed --- /dev/null +++ b/tests/handler/test_handler.py @@ -0,0 +1,115 @@ +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock + +import grpc +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +import modservice.handler.handler as handler_module +from modservice.grpc import mod_pb2 +from modservice.handler.handler import ModHandler +from modservice.service.service import ModService + + +def _build_create_pair( + faker: Faker, +) -> tuple[mod_pb2.CreateModRequest, mod_pb2.CreateModResponse]: + mod_id = faker.random_int(min=1, max=100000) + s3_key = f"{faker.random_int(min=1, max=100000)}/{mod_id}" + response = mod_pb2.CreateModResponse( + mod_id=mod_id, + upload_url=faker.uri(), + s3_key=s3_key, + ) + request = mod_pb2.CreateModRequest( + title=faker.sentence(nb_words=3), + author_id=faker.random_int(min=1, max=100000), + filename=faker.file_name(), + description=faker.text(), + ) + return request, response + + +def _build_download_link_pair( + faker: Faker, +) -> tuple[ + mod_pb2.GetModDownloadLinkRequest, mod_pb2.GetModDownloadLinkResponse +]: + mod_id = faker.random_int(min=1, max=100000) + response = mod_pb2.GetModDownloadLinkResponse(link_url=faker.uri()) + request = mod_pb2.GetModDownloadLinkRequest(mod_id=mod_id) + return request, response + + +def _build_set_status_pair( + faker: Faker, +) -> tuple[mod_pb2.SetStatusRequest, mod_pb2.SetStatusResponse]: + request = mod_pb2.SetStatusRequest( + mod_id=faker.random_int(min=1, max=100000), + status=mod_pb2.ModStatus.MOD_STATUS_BANNED, + ) + response = mod_pb2.SetStatusResponse(success=True) + return request, response + + +def _build_get_mods_pair( + faker: Faker, +) -> tuple[mod_pb2.GetModsRequest, mod_pb2.GetModsResponse]: + response = mod_pb2.GetModsResponse() + response.mods.add( + id=faker.random_int(min=1, max=100000), + author_id=faker.random_int(min=1, max=100000), + title=faker.sentence(nb_words=3), + description=faker.text(), + version=1, + status=mod_pb2.ModStatus.MOD_STATUS_UPLOADED, + ) + request = mod_pb2.GetModsRequest() + return request, response + + +@dataclass(frozen=True) +class HandlerCase: + method_name: str + helper_attr: str + builder: Callable[[Faker], tuple[Any, Any]] + + +CASES: tuple[HandlerCase, ...] = ( + HandlerCase("CreateMod", "_create_mod", _build_create_pair), + HandlerCase( + "GetModDownloadLink", + "_get_mod_download_link", + _build_download_link_pair, + ), + HandlerCase("SetStatus", "_set_status", _build_set_status_pair), + HandlerCase("GetMods", "_get_mods", _build_get_mods_pair), +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", CASES, ids=lambda case: case.method_name) +async def test_mod_handler_delegates_to_helper( + mocker: MockerFixture, + faker: Faker, + case: HandlerCase, +) -> None: + service = mocker.Mock(spec=ModService) + handler = ModHandler(service) + request, expected_response = case.builder(faker) + + helper = mocker.patch.object( + handler_module, + case.helper_attr, + new=AsyncMock(return_value=expected_response), + ) + context = mocker.Mock(spec=grpc.ServicerContext) + + method = getattr(handler, case.method_name) + result = await method(request, context) + + assert result is expected_response + helper.assert_awaited_once_with(service, request, context) diff --git a/tests/handler/test_set_status.py b/tests/handler/test_set_status.py new file mode 100644 index 0000000..7e9c4c6 --- /dev/null +++ b/tests/handler/test_set_status.py @@ -0,0 +1,76 @@ +from unittest.mock import AsyncMock + +import grpc +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.grpc import mod_pb2 +from modservice.handler.set_status import SetStatus +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_set_status_success(mocker: MockerFixture, faker: Faker) -> None: + context = mocker.Mock(spec=grpc.ServicerContext) + service = mocker.Mock(spec=ModService) + service.set_status = AsyncMock(return_value=True) + + mod_id = faker.random_int(min=1, max=100000) + request = mod_pb2.SetStatusRequest( + mod_id=mod_id, + status=mod_pb2.ModStatus.MOD_STATUS_UPLOADED, + ) + + response = await SetStatus(service, request, context) + + assert isinstance(response, mod_pb2.SetStatusResponse) + assert response.success is True + service.set_status.assert_awaited_once_with(mod_id, "UPLOADED") + context.set_code.assert_not_called() + context.set_details.assert_not_called() + + +@pytest.mark.asyncio +async def test_set_status_invalid_enum_sets_error( + mocker: MockerFixture, faker: Faker +) -> None: + context = mocker.Mock(spec=grpc.ServicerContext) + service = mocker.Mock(spec=ModService) + service.set_status = AsyncMock() + + request = mod_pb2.SetStatusRequest( + mod_id=faker.random_int(min=1, max=100000), + status=mod_pb2.ModStatus.MOD_STATUS_UNSPECIFIED, + ) + + response = await SetStatus(service, request, context) + + assert response.success is False + service.set_status.assert_not_called() + context.set_code.assert_called_once_with(grpc.StatusCode.INVALID_ARGUMENT) + context.set_details.assert_called_once_with("Status must be specified") + + +@pytest.mark.asyncio +async def test_set_status_internal_error_sets_context( + mocker: MockerFixture, faker: Faker +) -> None: + context = mocker.Mock(spec=grpc.ServicerContext) + service = mocker.Mock(spec=ModService) + error = RuntimeError(faker.sentence()) + service.set_status = AsyncMock(side_effect=error) + + request = mod_pb2.SetStatusRequest( + mod_id=faker.random_int(min=1, max=100000), + status=mod_pb2.ModStatus.MOD_STATUS_BANNED, + ) + + response = await SetStatus(service, request, context) + + assert response.success is False + context.set_code.assert_called_once_with(grpc.StatusCode.INTERNAL) + assert ( + context.set_details.call_args.args[0] + == f"Failed to set status: {error!s}" + ) diff --git a/tests/repository/test_create_mod.py b/tests/repository/test_create_mod.py new file mode 100644 index 0000000..ca51235 --- /dev/null +++ b/tests/repository/test_create_mod.py @@ -0,0 +1,48 @@ +import textwrap + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository + + +@pytest.mark.asyncio +async def test_repo_create_mod_inserts_row( + mocker: MockerFixture, faker: Faker +) -> None: + mod_id = faker.random_int(min=1, max=100000) + conn = mocker.Mock() + conn.fetchval = mocker.AsyncMock(return_value=mod_id) + acquire_cm = mocker.AsyncMock() + acquire_cm.__aenter__.return_value = conn + acquire_cm.__aexit__.return_value = None + pool = mocker.Mock() + pool.acquire.return_value = acquire_cm + + repo = ModRepository(pool) + + title = faker.sentence(nb_words=3) + author_id = faker.random_int(min=1, max=100000) + description = faker.text() + + result = await repo.create_mod(title, author_id, description) + + assert result == mod_id + expected_sql = """ + INSERT INTO mods (author_id, title, description, version, status, created_at) + VALUES ($1, $2, $3, $4, 'UPLOADING', NOW()) + RETURNING id + """ + actual_sql = conn.fetchval.await_args.args[0] + assert ( + textwrap.dedent(actual_sql).strip() + == textwrap.dedent(expected_sql).strip() + ) + assert conn.fetchval.await_args.args[1:] == ( + author_id, + title, + description, + 1, + ) + pool.acquire.assert_called_once() diff --git a/tests/repository/test_get_mod_s3_key.py b/tests/repository/test_get_mod_s3_key.py new file mode 100644 index 0000000..cdb5a3b --- /dev/null +++ b/tests/repository/test_get_mod_s3_key.py @@ -0,0 +1,56 @@ +import textwrap + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.get_mod_s3_key import get_mod_s3_key + + +@pytest.mark.asyncio +async def test_get_mod_s3_key_returns_value( + mocker: MockerFixture, faker: Faker +) -> None: + conn = mocker.Mock() + s3_key = f"{faker.random_int(min=1, max=100000)}/{faker.random_int(min=1, max=100000)}" + conn.fetchval = mocker.AsyncMock(return_value=s3_key) + acquire_cm = mocker.AsyncMock() + acquire_cm.__aenter__.return_value = conn + acquire_cm.__aexit__.return_value = None + pool = mocker.Mock() + pool.acquire.return_value = acquire_cm + + mod_id = faker.random_int(min=1, max=100000) + result = await get_mod_s3_key(pool, mod_id) + + assert result == s3_key + expected_sql = """ + SELECT s3_key + FROM mods + WHERE id = $1 + AND status = 'UPLOADED'; + """ + actual_sql = conn.fetchval.await_args.args[0] + assert ( + textwrap.dedent(actual_sql).strip() + == textwrap.dedent(expected_sql).strip() + ) + assert conn.fetchval.await_args.args[1:] == (mod_id,) + + +@pytest.mark.asyncio +async def test_get_mod_s3_key_returns_zero_when_missing( + mocker: MockerFixture, faker: Faker +) -> None: + conn = mocker.Mock() + conn.fetchval = mocker.AsyncMock(return_value=None) + acquire_cm = mocker.AsyncMock() + acquire_cm.__aenter__.return_value = conn + acquire_cm.__aexit__.return_value = None + pool = mocker.Mock() + pool.acquire.return_value = acquire_cm + + mod_id = faker.random_int(min=1, max=100000) + result = await get_mod_s3_key(pool, mod_id) + + assert result == 0 diff --git a/tests/repository/test_get_mods.py b/tests/repository/test_get_mods.py new file mode 100644 index 0000000..94b75f6 --- /dev/null +++ b/tests/repository/test_get_mods.py @@ -0,0 +1,72 @@ +import textwrap +from datetime import UTC + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.get_mods import get_mods + + +@pytest.mark.asyncio +async def test_get_mods_maps_rows(mocker: MockerFixture, faker: Faker) -> None: + created_at_first = faker.date_time(tzinfo=UTC) + created_at_second = faker.date_time(tzinfo=UTC) + rows = [ + { + "id": faker.random_int(min=1, max=100000), + "author_id": faker.random_int(min=1, max=100000), + "title": faker.sentence(nb_words=3), + "description": faker.text(), + "version": 1, + "s3_key": faker.file_path(depth=2), + "status": "UPLOADED", + "created_at": created_at_first, + }, + { + "id": faker.random_int(min=1, max=100000), + "author_id": faker.random_int(min=1, max=100000), + "title": faker.sentence(nb_words=4), + "description": faker.text(), + "version": 2, + "s3_key": faker.file_path(depth=2), + "status": "BANNED", + "created_at": created_at_second, + }, + ] + + conn = mocker.Mock() + conn.fetch = mocker.AsyncMock(return_value=rows) + acquire_cm = mocker.AsyncMock() + acquire_cm.__aenter__.return_value = conn + acquire_cm.__aexit__.return_value = None + pool = mocker.Mock() + pool.acquire.return_value = acquire_cm + + result = await get_mods(pool) + + assert len(result) == 2 + assert result[0]["id"] == rows[0]["id"] + assert result[0]["status"] == rows[0]["status"] + assert result[0]["created_at"] == created_at_first + assert result[1]["id"] == rows[1]["id"] + assert result[1]["status"] == rows[1]["status"] + + expected_sql = """ + SELECT + id, + author_id, + title, + description, + version, + s3_key, + status, + created_at + FROM mods + ORDER BY created_at DESC + """ + actual_sql = conn.fetch.await_args.args[0] + assert ( + textwrap.dedent(actual_sql).strip() + == textwrap.dedent(expected_sql).strip() + ) diff --git a/tests/repository/test_insert_s3_key.py b/tests/repository/test_insert_s3_key.py new file mode 100644 index 0000000..53bab60 --- /dev/null +++ b/tests/repository/test_insert_s3_key.py @@ -0,0 +1,40 @@ +import textwrap + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.insert_s3_key import insert_s3_key + + +@pytest.mark.asyncio +async def test_insert_s3_key_updates_row( + mocker: MockerFixture, faker: Faker +) -> None: + conn = mocker.Mock() + conn.execute = mocker.AsyncMock(return_value="UPDATE 1") + acquire_cm = mocker.AsyncMock() + acquire_cm.__aenter__.return_value = conn + acquire_cm.__aexit__.return_value = None + pool = mocker.Mock() + pool.acquire.return_value = acquire_cm + + mod_id = faker.random_int(min=1, max=100000) + author_id = faker.random_int(min=1, max=100000) + + s3_key = await insert_s3_key(pool, mod_id, author_id) + + expected_key = f"{author_id}/{mod_id}" + assert s3_key == expected_key + + actual_sql = conn.execute.await_args.args[0] + expected_sql = """ + UPDATE mods + SET s3_key = $1 + WHERE id = $2 + """ + assert ( + textwrap.dedent(actual_sql).strip() + == textwrap.dedent(expected_sql).strip() + ) + assert conn.execute.await_args.args[1:] == (expected_key, mod_id) diff --git a/tests/repository/test_set_status.py b/tests/repository/test_set_status.py new file mode 100644 index 0000000..3b138c7 --- /dev/null +++ b/tests/repository/test_set_status.py @@ -0,0 +1,52 @@ +import textwrap + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.set_status import set_status + + +@pytest.mark.asyncio +async def test_set_status_returns_true_on_update( + mocker: MockerFixture, faker: Faker +) -> None: + conn = mocker.Mock() + conn.execute = mocker.AsyncMock(return_value="UPDATE 1") + acquire_cm = mocker.AsyncMock() + acquire_cm.__aenter__.return_value = conn + acquire_cm.__aexit__.return_value = None + pool = mocker.Mock() + pool.acquire.return_value = acquire_cm + + mod_id = faker.random_int(min=1, max=100000) + status = "UPLOADED" + + result = await set_status(pool, mod_id, status) + + assert result is True + expected_sql = """ + UPDATE mods + SET status = $1 + WHERE id = $2 + """ + actual_sql = conn.execute.await_args.args[0] + assert ( + textwrap.dedent(actual_sql).strip() + == textwrap.dedent(expected_sql).strip() + ) + assert conn.execute.await_args.args[1:] == (status, mod_id) + + +@pytest.mark.asyncio +async def test_set_status_returns_false_on_exception( + mocker: MockerFixture, faker: Faker +) -> None: + pool = mocker.Mock() + pool.acquire.side_effect = RuntimeError("boom") + + result = await set_status( + pool, faker.random_int(min=1, max=100000), "BANNED" + ) + + assert result is False diff --git a/tests/service/test_create_mod.py b/tests/service/test_create_mod.py new file mode 100644 index 0000000..d1bdd00 --- /dev/null +++ b/tests/service/test_create_mod.py @@ -0,0 +1,37 @@ +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository +from modservice.service.s3_service import S3Service +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_service_create_mod_invokes_helper( + mocker: MockerFixture, faker: Faker +) -> None: + repo = mocker.Mock(spec=ModRepository) + s3_service = mocker.Mock(spec=S3Service) + service = ModService(repo, s3_service) + + expected = ( + faker.random_int(min=1, max=100000), + f"{faker.random_int(min=1, max=100000)}/{faker.random_int(min=1, max=100000)}", + faker.uri(), + ) + helper = AsyncMock(return_value=expected) + mocker.patch("modservice.service.service._create_mod", helper) + + title = faker.sentence(nb_words=3) + author_id = faker.random_int(min=1, max=100000) + description = faker.text() + + result = await service.create_mod(title, author_id, description) + + assert result == expected + helper.assert_awaited_once_with( + repo, s3_service, title, author_id, description + ) diff --git a/tests/service/test_generate_mod_download_url.py b/tests/service/test_generate_mod_download_url.py new file mode 100644 index 0000000..afa6b07 --- /dev/null +++ b/tests/service/test_generate_mod_download_url.py @@ -0,0 +1,31 @@ +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository +from modservice.service.s3_service import S3Service +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_service_generate_mod_download_url( + mocker: MockerFixture, faker: Faker +) -> None: + repo = mocker.Mock(spec=ModRepository) + s3_service = mocker.Mock(spec=S3Service) + download_url = faker.uri() + s3_service.generate_mod_download_url = AsyncMock(return_value=download_url) + + service = ModService(repo, s3_service) + + prefix = faker.file_path(depth=1) + expiration = faker.random_int(min=100, max=10000) + + result = await service.generate_mod_download_url(prefix, expiration) + + assert result == download_url + s3_service.generate_mod_download_url.assert_awaited_once_with( + prefix, expiration + ) diff --git a/tests/service/test_generate_mod_upload_url.py b/tests/service/test_generate_mod_upload_url.py new file mode 100644 index 0000000..a12c42d --- /dev/null +++ b/tests/service/test_generate_mod_upload_url.py @@ -0,0 +1,31 @@ +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository +from modservice.service.s3_service import S3Service +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_service_generate_mod_upload_url( + mocker: MockerFixture, faker: Faker +) -> None: + repo = mocker.Mock(spec=ModRepository) + s3_service = mocker.Mock(spec=S3Service) + upload_url = faker.uri() + s3_service.generate_mod_upload_url = AsyncMock(return_value=upload_url) + + service = ModService(repo, s3_service) + + prefix = faker.file_path(depth=1) + expiration = faker.random_int(min=100, max=10000) + + result = await service.generate_mod_upload_url(prefix, expiration) + + assert result == upload_url + s3_service.generate_mod_upload_url.assert_awaited_once_with( + prefix, expiration + ) diff --git a/tests/service/test_generate_s3_key.py b/tests/service/test_generate_s3_key.py new file mode 100644 index 0000000..3b4cb60 --- /dev/null +++ b/tests/service/test_generate_s3_key.py @@ -0,0 +1,30 @@ +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository +from modservice.service.s3_service import S3Service +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_service_generate_s3_key_uses_s3_service( + mocker: MockerFixture, faker: Faker +) -> None: + repo = mocker.Mock(spec=ModRepository) + s3_service = mocker.Mock(spec=S3Service) + expected_key = faker.file_path(depth=2) + s3_service.generate_s3_key.return_value = expected_key + + service = ModService(repo, s3_service) + + author_id = faker.random_int(min=1, max=100000) + filename = faker.file_name() + title = faker.sentence(nb_words=2) + + result = await service.generate_s3_key(author_id, filename, title) + + assert result == expected_key + s3_service.generate_s3_key.assert_called_once_with( + author_id, filename, title + ) diff --git a/tests/service/test_generate_upload_url.py b/tests/service/test_generate_upload_url.py new file mode 100644 index 0000000..14ddbf2 --- /dev/null +++ b/tests/service/test_generate_upload_url.py @@ -0,0 +1,36 @@ +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository +from modservice.service.s3_service import S3Service +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_service_generate_upload_url_calls_s3_service( + mocker: MockerFixture, faker: Faker +) -> None: + repo = mocker.Mock(spec=ModRepository) + s3_service = mocker.Mock(spec=S3Service) + expected = (faker.file_path(depth=2), faker.uri()) + s3_service.generate_upload_url = AsyncMock(return_value=expected) + + service = ModService(repo, s3_service) + + author_id = faker.random_int(min=1, max=100000) + filename = faker.file_name() + title = faker.sentence(nb_words=2) + expiration = faker.random_int(min=100, max=10000) + content_type = "application/zip" + + result = await service.generate_upload_url( + author_id, filename, title, expiration, content_type + ) + + assert result == expected + s3_service.generate_upload_url.assert_awaited_once_with( + author_id, filename, title, expiration, content_type + ) diff --git a/tests/service/test_get_file_info_from_s3_key.py b/tests/service/test_get_file_info_from_s3_key.py new file mode 100644 index 0000000..dab530f --- /dev/null +++ b/tests/service/test_get_file_info_from_s3_key.py @@ -0,0 +1,28 @@ +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository +from modservice.service.s3_service import S3Service +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_service_get_file_info_from_s3_key_passes_through( + mocker: MockerFixture, faker: Faker +) -> None: + repo = mocker.Mock(spec=ModRepository) + s3_service = mocker.Mock(spec=S3Service) + info = { + "author_id": faker.random_int(min=1, max=100000), + "full_s3_key": faker.file_path(depth=2), + } + s3_service.get_file_info_from_s3_key.return_value = info + + service = ModService(repo, s3_service) + + s3_key = faker.file_path(depth=2) + result = await service.get_file_info_from_s3_key(s3_key) + + assert result is info + s3_service.get_file_info_from_s3_key.assert_called_once_with(s3_key) diff --git a/tests/service/test_get_mod_download_link.py b/tests/service/test_get_mod_download_link.py new file mode 100644 index 0000000..2dd0b1b --- /dev/null +++ b/tests/service/test_get_mod_download_link.py @@ -0,0 +1,35 @@ +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository +from modservice.service.s3_service import S3Service +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_service_get_mod_download_link( + mocker: MockerFixture, faker: Faker +) -> None: + repo = mocker.Mock(spec=ModRepository) + s3_service = mocker.Mock(spec=S3Service) + + s3_key = faker.file_path(depth=2) + repo.get_mod_s3_key = AsyncMock(return_value=s3_key) + download_url = faker.uri() + s3_service.generate_mod_download_url = AsyncMock(return_value=download_url) + + service = ModService(repo, s3_service) + + mod_id = faker.random_int(min=1, max=100000) + expiration = faker.random_int(min=100, max=10000) + + result = await service.get_mod_download_link(mod_id, expiration) + + assert result == download_url + repo.get_mod_s3_key.assert_awaited_once_with(mod_id) + s3_service.generate_mod_download_url.assert_awaited_once_with( + s3_key, expiration + ) diff --git a/tests/service/test_get_mods.py b/tests/service/test_get_mods.py new file mode 100644 index 0000000..2dceb7d --- /dev/null +++ b/tests/service/test_get_mods.py @@ -0,0 +1,27 @@ +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository +from modservice.service.s3_service import S3Service +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_service_get_mods_returns_helper_result( + mocker: MockerFixture, faker: Faker +) -> None: + repo = mocker.Mock(spec=ModRepository) + s3_service = mocker.Mock(spec=S3Service) + mods = [{"id": faker.random_int(), "title": faker.word()}] + helper = AsyncMock(return_value=mods) + mocker.patch("modservice.service.service._get_mods", helper) + + service = ModService(repo, s3_service) + + result = await service.get_mods() + + assert result == mods + helper.assert_awaited_once_with(repo) diff --git a/tests/service/test_s3_client.py b/tests/service/test_s3_client.py new file mode 100644 index 0000000..27d10ce --- /dev/null +++ b/tests/service/test_s3_client.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from contextlib import AbstractAsyncContextManager +from pathlib import Path +from types import TracebackType +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest +from pytest_mock import MockerFixture + +from modservice.s3_client import S3Client + + +class _ValueContext[T](AbstractAsyncContextManager[T]): + def __init__(self, value: T) -> None: + self._value = value + + async def __aenter__(self) -> T: + await asyncio.sleep(0) + return self._value + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool: + await asyncio.sleep(0) + return False + + +def _async_cm[T](value: T) -> AbstractAsyncContextManager[T]: + return _ValueContext(value) + + +class _FakeBodyStream: + def __init__(self, data: bytes): + self._data = data + + async def __aenter__(self) -> _FakeBodyStream: + await asyncio.sleep(0) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool: + await asyncio.sleep(0) + return False + + async def read(self) -> bytes: + await asyncio.sleep(0) + return self._data + + +class _FakePaginator: + def __init__(self, pages: list[dict[str, Any]]) -> None: + self._pages: list[dict[str, Any]] = pages + self.called_with: list[dict[str, Any]] = [] + + def paginate(self, **kwargs: Any) -> _AsyncPageIterator: + self.called_with.append(kwargs) + + return _AsyncPageIterator(self._pages) + + +class _AsyncPageIterator: + def __init__(self, pages: list[dict[str, Any]]) -> None: + self._pages: Iterator[dict[str, Any]] = iter(pages) + + def __aiter__(self) -> _AsyncPageIterator: + return self + + async def __anext__(self) -> dict[str, Any]: + await asyncio.sleep(0) + try: + return next(self._pages) + except StopIteration as exc: + raise StopAsyncIteration from exc + + +@pytest.fixture +def s3_client_and_session( + mocker: MockerFixture, +) -> tuple[S3Client, Mock]: + session: Mock = mocker.Mock() + mocker.patch("modservice.s3_client.aioboto3.Session", return_value=session) + client = S3Client( + access_key="access", + secret_key="secret", + endpoint_url="https://example.com", + bucket_name="bucket", + verify=True, + ) + return client, session + + +def test_get_client_uses_session_client( + s3_client_and_session: tuple[S3Client, Mock], + mocker: MockerFixture, +) -> None: + s3_client, session = s3_client_and_session + underlying_client: Mock = mocker.Mock() + session.client.return_value = _async_cm(underlying_client) + + context_manager: AbstractAsyncContextManager[Any] = s3_client.get_client() + + session.client.assert_called_once() + assert session.client.call_args.args == ("s3",) + assert session.client.call_args.kwargs == { + "endpoint_url": "https://example.com", + "aws_access_key_id": "access", + "aws_secret_access_key": "secret", + "config": s3_client.config, + "verify": True, + "region_name": "ru-central-1", + } + assert context_manager is session.client.return_value + + +@pytest.mark.asyncio +async def test_upload_file_puts_object( + tmp_path: Path, + s3_client_and_session: tuple[S3Client, Mock], + mocker: MockerFixture, +) -> None: + s3_client, _ = s3_client_and_session + file_path = tmp_path / "payload.bin" + payload = b"hello-world" + file_path.write_bytes(payload) + s3_key = "mods/payload.bin" + + storage_client: Mock = mocker.Mock() + storage_client.put_object = AsyncMock(return_value=None) + mocker.patch.object( + s3_client, "get_client", return_value=_async_cm(storage_client) + ) + + result = await s3_client.upload_file(str(file_path), s3_key) + + assert result is True + storage_client.put_object.assert_awaited_once_with( + Bucket="bucket", + Key=s3_key, + Body=payload, + ) + + +@pytest.mark.asyncio +async def test_upload_file_returns_false_on_error( + tmp_path: Path, + s3_client_and_session: tuple[S3Client, Mock], + mocker: MockerFixture, +) -> None: + s3_client, _ = s3_client_and_session + file_path = tmp_path / "payload.bin" + file_path.write_bytes(b"boom") + + storage_client: Mock = mocker.Mock() + storage_client.put_object = AsyncMock(side_effect=RuntimeError("boom")) + mocker.patch.object( + s3_client, "get_client", return_value=_async_cm(storage_client) + ) + + result = await s3_client.upload_file(str(file_path), "mods/payload.bin") + + assert result is False + + +@pytest.mark.asyncio +async def test_download_file_writes_to_disk( + tmp_path: Path, + s3_client_and_session: tuple[S3Client, Mock], + mocker: MockerFixture, +) -> None: + s3_client, _ = s3_client_and_session + destination = tmp_path / "nested" / "file.bin" + + storage_client: Mock = mocker.Mock() + storage_client.get_object = AsyncMock( + return_value={"Body": _FakeBodyStream(b"downloaded")} + ) + mocker.patch.object( + s3_client, "get_client", return_value=_async_cm(storage_client) + ) + + result = await s3_client.download_file("mods/file.bin", str(destination)) + + assert result is True + assert destination.exists() + assert destination.read_bytes() == b"downloaded" + storage_client.get_object.assert_awaited_once_with( + Bucket="bucket", + Key="mods/file.bin", + ) + + +@pytest.mark.asyncio +async def test_generate_presigned_put_url_uses_client( + s3_client_and_session: tuple[S3Client, Mock], + mocker: MockerFixture, +) -> None: + s3_client, _ = s3_client_and_session + storage_client: Mock = mocker.Mock() + storage_client.generate_presigned_url = AsyncMock( + return_value="https://put-url" + ) + mocker.patch.object( + s3_client, "get_client", return_value=_async_cm(storage_client) + ) + + url = await s3_client.generate_presigned_put_url( + "/mods/file.bin", + expiration=600, + content_type="application/zip", + ) + + assert url == "https://put-url" + storage_client.generate_presigned_url.assert_awaited_once_with( + "put_object", + Params={ + "Bucket": "bucket", + "Key": "mods/file.bin", + "ContentType": "application/zip", + }, + ExpiresIn=600, + ) + + +@pytest.mark.asyncio +async def test_generate_presigned_get_url_uses_client( + s3_client_and_session: tuple[S3Client, Mock], + mocker: MockerFixture, +) -> None: + s3_client, _ = s3_client_and_session + storage_client: Mock = mocker.Mock() + storage_client.generate_presigned_url = AsyncMock( + return_value="https://get-url" + ) + mocker.patch.object( + s3_client, "get_client", return_value=_async_cm(storage_client) + ) + + url = await s3_client.generate_presigned_get_url( + "/mods/file.bin", + expiration=1200, + ) + + assert url == "https://get-url" + storage_client.generate_presigned_url.assert_awaited_once_with( + "get_object", + Params={ + "Bucket": "bucket", + "Key": "mods/file.bin", + }, + ExpiresIn=1200, + ) + + +@pytest.mark.asyncio +async def test_list_objects_collects_all_pages( + s3_client_and_session: tuple[S3Client, Mock], + mocker: MockerFixture, +) -> None: + s3_client, _ = s3_client_and_session + pages = [ + { + "Contents": [ + { + "Key": "mods/1", + "Size": 10, + "LastModified": "ts1", + "ETag": "tag1", + } + ] + }, + { + "Contents": [ + { + "Key": "mods/2", + "Size": 20, + "LastModified": "ts2", + "ETag": "tag2", + } + ] + }, + ] + + storage_client: Mock = mocker.Mock() + paginator = _FakePaginator(pages) + storage_client.get_paginator.return_value = paginator + mocker.patch.object( + s3_client, "get_client", return_value=_async_cm(storage_client) + ) + + result = await s3_client.list_objects("mods/") + + assert result == [ + {"key": "mods/1", "size": 10, "last_modified": "ts1", "etag": "tag1"}, + {"key": "mods/2", "size": 20, "last_modified": "ts2", "etag": "tag2"}, + ] + storage_client.get_paginator.assert_called_once_with("list_objects_v2") + assert paginator.called_with == [{"Bucket": "bucket", "Prefix": "mods/"}] + + +def test_time_format_formats_values( + s3_client_and_session: tuple[S3Client, Mock], +) -> None: + s3_client, _ = s3_client_and_session + + assert s3_client.time_format(3661) == "01h 01m 01s" + assert s3_client.time_format(61) == "01m 01s" + assert s3_client.time_format(5) == "05s" + assert s3_client.time_format(0) == "-" + assert s3_client.time_format(None) == "-" diff --git a/tests/service/test_s3_service.py b/tests/service/test_s3_service.py index 8eff9a1..b20c83a 100644 --- a/tests/service/test_s3_service.py +++ b/tests/service/test_s3_service.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -10,11 +10,11 @@ class TestS3Service: @pytest.fixture def mock_s3_client(self) -> MagicMock: client = MagicMock(spec=S3Client) - client.generate_presigned_put_url = MagicMock() - client.generate_presigned_get_url = MagicMock() - client.upload_file = MagicMock() - client.download_file = MagicMock() - client.list_objects = MagicMock() + client.generate_presigned_put_url = AsyncMock() + client.generate_presigned_get_url = AsyncMock() + client.upload_file = AsyncMock() + client.download_file = AsyncMock() + client.list_objects = AsyncMock() return client @pytest.fixture @@ -74,7 +74,8 @@ def test_generate_s3_key_unique(self, s3_service: S3Service) -> None: assert key1 != key3 # Разные filename assert key2 != key3 # Разные комбинации - def test_generate_upload_url( + @pytest.mark.asyncio + async def test_generate_upload_url( self, s3_service: S3Service, mock_s3_client: MagicMock ) -> None: """Тест генерации URL для загрузки с автогенерацией ключа""" @@ -89,7 +90,7 @@ def test_generate_upload_url( expected_presigned_url ) - s3_key, presigned_url = s3_service.generate_upload_url( + s3_key, presigned_url = await s3_service.generate_upload_url( author_id, filename, title, expiration, content_type ) @@ -98,13 +99,14 @@ def test_generate_upload_url( assert presigned_url == expected_presigned_url # Проверяем вызов generate_presigned_put_url - mock_s3_client.generate_presigned_put_url.assert_called_once_with( + mock_s3_client.generate_presigned_put_url.assert_awaited_once_with( s3_key=s3_key, expiration=expiration, content_type=content_type, ) - def test_generate_upload_url_for_key( + @pytest.mark.asyncio + async def test_generate_upload_url_for_key( self, s3_service: S3Service, mock_s3_client: MagicMock ) -> None: """Тест генерации URL для загрузки по существующему ключу""" @@ -117,20 +119,21 @@ def test_generate_upload_url_for_key( expected_presigned_url ) - presigned_url = s3_service.generate_upload_url_for_key( + presigned_url = await s3_service.generate_upload_url_for_key( s3_key, expiration, content_type ) assert presigned_url == expected_presigned_url # Проверяем вызов generate_presigned_put_url - mock_s3_client.generate_presigned_put_url.assert_called_once_with( + mock_s3_client.generate_presigned_put_url.assert_awaited_once_with( s3_key=s3_key, expiration=expiration, content_type=content_type, ) - def test_generate_download_url( + @pytest.mark.asyncio + async def test_generate_download_url( self, s3_service: S3Service, mock_s3_client: MagicMock ) -> None: """Тест генерации URL для скачивания""" @@ -142,17 +145,20 @@ def test_generate_download_url( expected_presigned_url ) - presigned_url = s3_service.generate_download_url(s3_key, expiration) + presigned_url = await s3_service.generate_download_url( + s3_key, expiration + ) assert presigned_url == expected_presigned_url # Проверяем вызов generate_presigned_get_url - mock_s3_client.generate_presigned_get_url.assert_called_once_with( + mock_s3_client.generate_presigned_get_url.assert_awaited_once_with( s3_key=s3_key, expiration=expiration, ) - def test_upload_file( + @pytest.mark.asyncio + async def test_upload_file( self, s3_service: S3Service, mock_s3_client: MagicMock ) -> None: """Тест загрузки файла""" @@ -161,12 +167,13 @@ def test_upload_file( mock_s3_client.upload_file.return_value = True - result = s3_service.upload_file(file_path, s3_key) + result = await s3_service.upload_file(file_path, s3_key) assert result is True - mock_s3_client.upload_file.assert_called_once_with(file_path, s3_key) + mock_s3_client.upload_file.assert_awaited_once_with(file_path, s3_key) - def test_download_file( + @pytest.mark.asyncio + async def test_download_file( self, s3_service: S3Service, mock_s3_client: MagicMock ) -> None: """Тест скачивания файла""" @@ -175,14 +182,15 @@ def test_download_file( mock_s3_client.download_file.return_value = True - result = s3_service.download_file(s3_key, local_path) + result = await s3_service.download_file(s3_key, local_path) assert result is True - mock_s3_client.download_file.assert_called_once_with( + mock_s3_client.download_file.assert_awaited_once_with( s3_key, local_path ) - def test_list_files( + @pytest.mark.asyncio + async def test_list_files( self, s3_service: S3Service, mock_s3_client: MagicMock ) -> None: """Тест получения списка файлов""" @@ -194,12 +202,13 @@ def test_list_files( mock_s3_client.list_objects.return_value = expected_files - result = s3_service.list_files(prefix) + result = await s3_service.list_files(prefix) assert result == expected_files - mock_s3_client.list_objects.assert_called_once_with(prefix) + mock_s3_client.list_objects.assert_awaited_once_with(prefix) - def test_generate_upload_url_auto_content_type( + @pytest.mark.asyncio + async def test_generate_upload_url_auto_content_type( self, s3_service: S3Service, mock_s3_client: MagicMock ) -> None: """Тест автоопределения content-type""" @@ -211,16 +220,18 @@ def test_generate_upload_url_auto_content_type( expected_presigned_url ) - _s3_key, _presigned_url = s3_service.generate_upload_url( + _s3_key, _presigned_url = await s3_service.generate_upload_url( author_id, filename ) # noqa: ARG001 # Проверяем, что content_type был автоматически определен - mock_s3_client.generate_presigned_put_url.assert_called_once() + mock_s3_client.generate_presigned_put_url.assert_awaited_once() ( _args, kwargs, - ) = mock_s3_client.generate_presigned_put_url.call_args # noqa: ARG001 + ) = ( + mock_s3_client.generate_presigned_put_url.await_args + ) # noqa: ARG001 assert kwargs["s3_key"].startswith(f"{author_id}/") assert kwargs["expiration"] == 3600 # Значение по умолчанию diff --git a/tests/service/test_service.py b/tests/service/test_service.py deleted file mode 100644 index 81127b3..0000000 --- a/tests/service/test_service.py +++ /dev/null @@ -1,116 +0,0 @@ -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from modservice.repository.repository import ModRepository -from modservice.service.s3_service import S3Service -from modservice.service.service import ModService - - -class TestModService: - @pytest.fixture - def mock_repo(self) -> MagicMock: - return MagicMock(spec=ModRepository) - - @pytest.fixture - def mock_s3_service(self) -> MagicMock: - return MagicMock(spec=S3Service) - - @pytest.fixture - def mod_service( - self, mock_repo: MagicMock, mock_s3_service: MagicMock - ) -> ModService: - return ModService(mock_repo, mock_s3_service) - - async def test_generate_s3_key( - self, mod_service: ModService, mock_s3_service: MagicMock - ) -> None: - author_id = 123 - filename = "test_mod.zip" - title = "Test Mod" - expected_key = "mods/123/20231201_120000_Test_Mod.zip" - - mock_s3_service.generate_s3_key.return_value = expected_key - - result = await mod_service.generate_s3_key(author_id, filename, title) - - assert result == expected_key - mock_s3_service.generate_s3_key.assert_called_once_with( - author_id, filename, title - ) - - async def test_generate_upload_url( - self, mod_service: ModService, mock_s3_service: MagicMock - ) -> None: - author_id = 456 - filename = "awesome_mod.rar" - title = "Awesome Mod" - expiration = 7200 - content_type = "application/x-rar-compressed" - - expected_s3_key = "mods/456/20231201_130000_Awesome_Mod.rar" - expected_url = "https://example.com/presigned-url" - - mock_s3_service.generate_upload_url.return_value = ( - expected_s3_key, - expected_url, - ) - - s3_key, presigned_url = await mod_service.generate_upload_url( - author_id, filename, title, expiration, content_type - ) - - assert s3_key == expected_s3_key - assert presigned_url == expected_url - - mock_s3_service.generate_upload_url.assert_called_once_with( - author_id, filename, title, expiration, content_type - ) - - async def test_get_file_info_from_s3_key( - self, mod_service: ModService, mock_s3_service: MagicMock - ) -> None: - s3_key = "mods/789/20231201_140000_Test_File.zip" - expected_info: dict[str, Any] = { - "author_id": 789, - "timestamp": "20231201_140000", - "filename": "Test_File.zip", - "full_s3_key": s3_key, - } - - mock_s3_service.get_file_info_from_s3_key.return_value = expected_info - - result = await mod_service.get_file_info_from_s3_key(s3_key) - - assert result == expected_info - mock_s3_service.get_file_info_from_s3_key.assert_called_once_with( - s3_key - ) - - async def test_create_mod_delegates_to_repository( - self, mod_service: ModService - ) -> None: - title = "Test Mod" - author_id = 123 - description = "Test description" - - expected_result: tuple[int, str, str] = ( - 1, - "test_s3_key", - "test_upload_url", - ) - - # Мокаем функцию create_mod из модуля create_mod - with pytest.MonkeyPatch().context() as m: - mock_create_mod = MagicMock(return_value=expected_result) - m.setattr( - "modservice.service.service._create_mod", - mock_create_mod, - ) - - result = await mod_service.create_mod( - title, author_id, description - ) - - assert result == expected_result diff --git a/tests/service/test_set_status.py b/tests/service/test_set_status.py new file mode 100644 index 0000000..ef31157 --- /dev/null +++ b/tests/service/test_set_status.py @@ -0,0 +1,29 @@ +from unittest.mock import AsyncMock + +import pytest +from faker import Faker +from pytest_mock import MockerFixture + +from modservice.repository.repository import ModRepository +from modservice.service.s3_service import S3Service +from modservice.service.service import ModService + + +@pytest.mark.asyncio +async def test_service_set_status_uses_helper( + mocker: MockerFixture, faker: Faker +) -> None: + repo = mocker.Mock(spec=ModRepository) + s3_service = mocker.Mock(spec=S3Service) + helper = AsyncMock(return_value=True) + mocker.patch("modservice.service.service._set_status", helper) + + service = ModService(repo, s3_service) + + mod_id = faker.random_int(min=1, max=100000) + status = "UPLOADED" + + result = await service.set_status(mod_id, status) + + assert result is True + helper.assert_awaited_once_with(repo, mod_id, status) diff --git a/tmp_out.txt b/tmp_out.txt new file mode 100644 index 0000000..1e22278 --- /dev/null +++ b/tmp_out.txt @@ -0,0 +1,159 @@ +[tool.pdm] +distribution = true + +[tool.black] +line-length = 79 +target-version = ["py313"] +skip-string-normalization = false +exclude = ''' +/( + \.git + | \.hg + | \.venv + | venv + | build + | dist + | src/modservice/grpc +)/ +''' + +[tool.isort] +profile = "black" +line_length = 79 +known_first_party = ["app"] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +skip = [".venv", "src/modservice/grpc"] + +[tool.flake8] +max-line-length = 120 +extend-ignore = ["E203", "W503"] +per-file-ignores = [ + "__init__.py:F401", + "tests/*:T20,ARG001" +] +exclude = [ + ".venv", + "__pycache__", + "build", + "dist", + "src/modservice/grpc", +] + +[tool.ruff] +line-length = 120 +target-version = "py313" +preview = true +exclude = [".venv", "build", "dist", "src/modservice/grpc"] + +[tool.ruff.lint] +select = [ + "E", + "F", + "B", + "UP", + "SIM", + "T20", + "ARG", + "C4", + "RUF" +] +ignore = [ + "E203", + "B008", + "SIM105", + "RUF001", + "RUF100", + "RUF003", + "RUF002" +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["T20", "ARG001"] + +[tool.mypy] +python_version = "3.13" +strict = true +exclude = 'src(?:[/\\])modservice(?:[/\\])grpc' +warn_unused_configs = true +warn_return_any = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +show_error_codes = true +explicit_package_bases = true +mypy_path = ["src"] +plugins = ["pydantic.mypy"] + +[tool.pytest.ini_options] +addopts = "--import-mode=importlib" +asyncio_mode = "strict" +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.coverage.run] +source = ["src/modservice"] +relative_files = true + +[tool.coverage.report] +include = [ + "src/modservice/handler/*", + "src/modservice/service/*", + "src/modservice/repository/*", +] +omit = ["src/modservice/server.py"] + +[project] +name = "modservice" +version = "0.0.1" +description = "mod-service for esclient" +authors = [ + {name = "esclient"}, +] +dependencies = [ + "grpcio-reflection==1.75.1", + "pydantic==2.11.9", + "pydantic-settings==2.11.0", + "watchfiles==1.1.0", + "aioboto3>=13.0.0", + "python-dotenv>=1.0.0", + "asyncpg>=0.30.0", +] +requires-python = ">=3.13" +readme = "README.md" +license = {text = "MIT"} + +[project.scripts] +run-server = "modservice.server:main" + +[dependency-groups] +dev = [ + "grpcio==1.75.1", + "grpcio-tools==1.75.1", + "grpc-stubs==1.53.0.6", + "protobuf==6.32.1", + "pytest==8.4.2", + "pytest-asyncio==0.24.0", + "pytest-cov==5.0.0", + "pytest-mock==3.15.1", + "pytest-faker==2.0.0", + "Faker==37.11.0", + "black==25.9.0", + "isort==6.0.1", + "flake8==7.3.0", + "flake8-pyproject==1.2.3", + "ruff==0.13.2", + "mypy==1.18.2", + "types-protobuf==6.32.1.20250918", + "types-psycopg2==2.9.21.20250915", + "types-aioboto3==15.4.0", + "botocore-stubs==1.40.55", + "asyncpg-stubs==0.30.2", +] + diff --git a/tools/common.just b/tools/common.just index 883b36c..b4cdc97 100644 --- a/tools/common.just +++ b/tools/common.just @@ -39,6 +39,8 @@ lint: mypy --strict . test: - pytest + pytest \ + --cov=src/{{SOURCE}} \ + --cov-report=xml:coverage.xml prepare: format lint test