From 3bd7f0e657b4971c5b072d19064af0e8f3cdd359 Mon Sep 17 00:00:00 2001 From: Pdzly Date: Sat, 20 Dec 2025 16:54:32 +0100 Subject: [PATCH 1/4] chore: add `test` dependencies group and update `uv.lock` - Introduce `test` optional dependencies group in `uv.lock` for testing packages - Add pytest, pytest-asyncio, pytest-cov, faker, freezegun, httpx, and related dependencies - Include necessary metadata updates and package versions in `uv.lock` --- backend/pyproject.toml | 23 ++++ backend/uv.lock | 291 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 313 insertions(+), 1 deletion(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 86ad66e..7643033 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,6 +29,18 @@ migrations = [ dev = [ "ruff>=0.14.9", ] +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.12.0", + "httpx>=0.26.0", + "faker>=22.0.0", + "freezegun>=1.4.0", + "pytest-env>=1.1.3", + "pytest-xdist>=3.5.0", + "coverage[toml]>=7.4.0", +] [tool.uvicorn] factory = false @@ -39,3 +51,14 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages] find = { where = ["."], include = ["app*", "files*", "alembic*"] } + +[tool.coverage.run] +source = ["app"] +omit = ["*/tests/*", "*/__pycache__/*", "*/alembic/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] diff --git a/backend/uv.lock b/backend/uv.lock index d21a598..3614038 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -172,6 +172,18 @@ dev = [ migrations = [ { name = "psycopg2-binary" }, ] +test = [ + { name = "coverage" }, + { name = "faker" }, + { name = "freezegun" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-env" }, + { name = "pytest-mock" }, + { name = "pytest-xdist" }, +] [package.metadata] requires-dist = [ @@ -180,20 +192,39 @@ requires-dist = [ { name = "alembic", specifier = ">=1.17.2" }, { name = "argon2-cffi", specifier = ">=23.1.0" }, { name = "asyncpg", specifier = ">=0.29" }, + { name = "coverage", extras = ["toml"], marker = "extra == 'test'", specifier = ">=7.4.0" }, { name = "dependency-injector", specifier = ">=4.41" }, + { name = "faker", marker = "extra == 'test'", specifier = ">=22.0.0" }, { name = "fastapi", specifier = ">=0.124" }, { name = "fastapi-cors", specifier = ">=0.0.6" }, + { name = "freezegun", marker = "extra == 'test'", specifier = ">=1.4.0" }, + { name = "httpx", marker = "extra == 'test'", specifier = ">=0.26.0" }, { name = "orjson", specifier = ">=3.11.5" }, { name = "psycopg2-binary", marker = "extra == 'migrations'" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.1.0" }, + { name = "pytest-env", marker = "extra == 'test'", specifier = ">=1.1.3" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" }, + { name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=3.5.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.9" }, { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uuid", specifier = ">=1.30" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] -provides-extras = ["migrations", "dev"] +provides-extras = ["migrations", "dev", "test"] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] [[package]] name = "cffi" @@ -261,6 +292,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, + { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, + { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, + { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, + { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, + { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, + { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, + { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, + { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, + { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, + { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, + { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, +] + [[package]] name = "dependency-injector" version = "4.48.3" @@ -301,6 +393,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/f3/6961beb9a1e77d01dee1dd48f00fb3064429c8abcfa26aa863eb7cb2b6dd/environs-14.5.0-py3-none-any.whl", hash = "sha256:1abd3e3a5721fb09797438d6c902bc2f35d4580dfaffe68b8ee588b67b504e13", size = 17202, upload-time = "2025-11-02T21:30:35.186Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "faker" +version = "39.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/b9/0897fb5888ddda099dc0f314a8a9afb5faa7e52eaf6865c00686dfb394db/faker-39.0.0.tar.gz", hash = "sha256:ddae46d3b27e01cea7894651d687b33bcbe19a45ef044042c721ceac6d3da0ff", size = 1941757, upload-time = "2025-12-17T19:19:04.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/5a/26cdb1b10a55ac6eb11a738cea14865fa753606c4897d7be0f5dc230df00/faker-39.0.0-py3-none-any.whl", hash = "sha256:c72f1fca8f1a24b8da10fcaa45739135a19772218ddd61b86b7ea1b8c790dce7", size = 1980775, upload-time = "2025-12-17T19:19:02.926Z" }, +] + [[package]] name = "fastapi" version = "0.124.4" @@ -329,6 +442,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/ad/016bdc85603cca123527167cb3e3ca408f639f3ad71b8d51fd50b116b85e/fastapi_cors-0.0.6-py3-none-any.whl", hash = "sha256:d116b482c682f9c5330f04b1c49a9d504f3a9df6373bc43dd6c31f3b9d0b8b15", size = 4990, upload-time = "2023-07-12T17:17:01.679Z" }, ] +[[package]] +name = "freezegun" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, +] + [[package]] name = "greenlet" version = "3.3.0" @@ -369,6 +494,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -391,6 +529,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -400,6 +553,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "limits" version = "5.6.0" @@ -534,6 +696,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.11" @@ -655,6 +826,106 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-env" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/12/9c87d0ca45d5992473208bcef2828169fa7d39b8d7fc6e3401f5c08b8bf7/pytest_env-1.2.0.tar.gz", hash = "sha256:475e2ebe8626cee01f491f304a74b12137742397d6c784ea4bc258f069232b80", size = 8973, upload-time = "2025-10-09T19:15:47.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/98/822b924a4a3eb58aacba84444c7439fce32680592f394de26af9c76e2569/pytest_env-1.2.0-py3-none-any.whl", hash = "sha256:d7e5b7198f9b83c795377c09feefa45d56083834e60d04767efd64819fc9da00", size = 6251, upload-time = "2025-10-09T19:15:46.077Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -726,6 +997,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "slowapi" version = "0.1.9" @@ -800,6 +1080,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "uuid" version = "1.30" From 70818c7ca67a1ee61c006bb370fa31a6d70b61ac Mon Sep 17 00:00:00 2001 From: Pdzly Date: Sat, 20 Dec 2025 16:57:28 +0100 Subject: [PATCH 2/4] test: add comprehensive testing infrastructure and initial test suite - Introduce `tests/` directory with organized structure: unit, integration, API, and security - Add shared pytest fixtures in `conftest.py` for dependency injection and test configuration - Create `docker-compose.test.yml` to run PostgreSQL test database on port 5433 - Add `TESTING.md` to document test setup, running instructions, structure, and best practices - Include unit tests for token utility functions (`test_token_utils.py`) - Add API tests for paste endpoints (`test_paste_routes.py`) - Introduce pytest configuration and markers in `pytest.ini` - Add setup script (`setup_test_db.sh`) for test database initialization - Ensure test coverage integration with pytest plugins and CI compatibility --- backend/.coverage | Bin 0 -> 53248 bytes backend/TESTING.md | 257 +++++++++++++++++++++++++ backend/docker-compose.test.yml | 22 +++ backend/pytest.ini | 18 ++ backend/pytest_configure.py | 12 ++ backend/scripts/setup_test_db.sh | 48 +++++ backend/tests/__init__.py | 0 backend/tests/api/__init__.py | 0 backend/tests/api/conftest.py | 11 ++ backend/tests/api/test_paste_routes.py | 40 ++++ backend/tests/conftest.py | 130 +++++++++++++ backend/tests/integration/__init__.py | 0 backend/tests/security/__init__.py | 0 backend/tests/unit/__init__.py | 0 backend/tests/unit/test_token_utils.py | 60 ++++++ 15 files changed, 598 insertions(+) create mode 100644 backend/.coverage create mode 100644 backend/TESTING.md create mode 100644 backend/docker-compose.test.yml create mode 100644 backend/pytest.ini create mode 100644 backend/pytest_configure.py create mode 100755 backend/scripts/setup_test_db.sh create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/api/__init__.py create mode 100644 backend/tests/api/conftest.py create mode 100644 backend/tests/api/test_paste_routes.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/security/__init__.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_token_utils.py diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..ad5832cc6f4b8a0a6a06ba89074cde2ca2000175 GIT binary patch literal 53248 zcmeI5Yi!%r6~{@DqC`pLl^?Qf7=@X&t7Fu*;$~YPX&O6;^FW+9?Xp(~M4==)W+RCT zN!c-q#${373TWnZYZ22~zABq9PvH>l&Y{d#}AJ!Gyhb_UD1Vg)QSl1NYFf>_@cs^@Y#_f@R%FJX>XpdnnUwG}Lk>%`WGwZfbZ7F-R7HF$`K_2L7U2YLNB{{S z0VMGMO~83570V6`u%{lk)k0abO|__*-n+Ep;pwSE)5@W#?K9JgcTCxsP+%JyQ>GNt zSX647sm$qRP0_0*y{OuH^{8UcYkujvrIqMH2ghKkqXoy^`ZSl z+qzLzv=dsfZfm7g9UP_5{0-m*y#cKw)S)9@&BnC?bXt{Hso5yrs5}S8RSh3&t0KKL zRCaBy)++@-{%E~Yqm2VKybIgLS~b_IYa0z`KXTiw(v~fM+nUoyPFvKgRj=yD>sqt% zN0nC1QP+*CF{)4}pa*<1DP-^KVHxO^+6n7;89s02)w*rC`*~>m`~N?FXke_H~6V8AT%MK2*r$o0KHCn5Y#M-)$-;%3LqyDLirdoB= zpGZDzlJsk|W=VgHG|qrKV=*B+HMo{LjdJ;kMkemW_*gcRVI6No(0PfzqHRvlBdsBD z(?amk&9ABUyt2}EdKe5Jjtbc=nY9e3CgdmRyKwr;5!d8111|jvO#1Z2wV6zhv`pTo zEsZUxWtdHBs&2xNg*n(;0Tf!Wq(Wh}VAO55|I%Hzd(yS1Wv?gD+V|N*sS&q04DW-6 zW$Cc2(q+rK-#@Ah&#M-lg{)zvU>Idht$J$%)YxmmHFZmIZCJS~ZEI;*OH||N!mw+T zzqrt8{tyg0e|$lvQcW#tZ%`%QK@(1!cS(7(Nm!xk?vl5efC1s{ICV!WFCb>Hq zP5d-rCGHe2i_eMspbBnC00|%gB!C2v01{Yd1ZD?##<@K=Z&b9LX&A@!+!wThWy6Dz z4w}ZJTG6(0JGF)FdNo&2i^sHTDW}$I@X~WuyIcED zu!1G62HNo02p#lF1<=`-1)agLI(1twTe+EsKeu1280OLr6>c#Fv7LP&CWRHFUY2Yl zH*K1RNlnUhVp2P+nrZN+>s8&(=Rt0&2jpU%$a#$j5|iB^5e+M0 z!d<~-y`tNou&WCcLbp8yRMAVNvbG3MQ=n}2q(LVW))KGnZ3B8~-pzy3egaAzQKk(K z&q^`#?hVcEJ=tKG+=*d+KXThX55i#BZV8ln!n)`y1sZ-w3WSoK2n9s;Cp*CebQW}Y z&`Zv9tg#Eu9Y}!OhOjm@<>ocDY|rOgdJl@A$A;A_6=2S?=gsaM4-DJwOSypY_Jcr9Tk zhT}KLDftKTC#18|WpO4c$aB&`i4~tGTjcA>udcJ@9?eApNB{{S0VIF~kO0FRCN$fa+Y^Cgtnlp@8&Ll@0EXE%bxo5WQ}>^KX+VvgKZj1|Id2n{ORncZY*>09$GVTVKbZUhTJCM zN8fxWnPymKGQz-Z^RV%;E;y0yenAw|km>Eepq=Lu1hRecm;O3?v0IQKFN+C5qG^mJ zDM*#*g|}EvzWUxDUtqIIIIt;Fz3=Oltry>a;IRka_|*_Zl{12~oQSgG>64l3_gvfh zer+4Q+z&4?+`*xLzrM^|BXs{3-r{E`1(7oAWyLt8y*+<9d@|l2gY4a1)j#cLnA&gd z=9uR;GO0cHP6?5i0B6$VYg{G@nNj)WnYSN%H^rR1wB^dV-@iTpr{SwcaK2wO$ zG{r_)UKC}4{{G)j9%0B0@_jN-RPr);65{`FkhA1*vOxY$-X@pGZ^TnuFp-f88=Dx zx=EtPO@wYY>F#oq{?g-G||1thw zcg== 80% +- Linting must pass (ruff) + +### Viewing CI Results + +1. Go to GitHub Actions tab +2. Click on the latest workflow run +3. View test results and coverage report diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml new file mode 100644 index 0000000..5543cdc --- /dev/null +++ b/backend/docker-compose.test.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + devbin_test_db: + image: postgres:16 + container_name: devbin_test_db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: devbin_test + ports: + - "5433:5432" # Use different port to avoid conflicts + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + volumes: + - devbin_test_data:/var/lib/postgresql/data + +volumes: + devbin_test_data: diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..4aa6814 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,18 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +plugins = pytest_configure +addopts = + -v + --strict-markers + --tb=short + --cov=app + --cov-report=term-missing + --cov-report=html +markers = + unit: Unit tests (fast, isolated) + integration: Integration tests (database, file system) + security: Security-focused tests diff --git a/backend/pytest_configure.py b/backend/pytest_configure.py new file mode 100644 index 0000000..c55501c --- /dev/null +++ b/backend/pytest_configure.py @@ -0,0 +1,12 @@ +"""Pytest configuration plugin that sets environment variables early.""" +import os + + +def pytest_configure(config): + """Set test environment variables before any test collection.""" + os.environ.setdefault("APP_DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5433/devbin_test") + os.environ.setdefault("APP_BASE_FOLDER_PATH", "/tmp/devbin_test_files") + os.environ.setdefault("APP_DEBUG", "true") + # Use a test domain instead of wildcard to avoid validator issues + os.environ.setdefault("APP_CORS_DOMAINS", '["http://test"]') + os.environ.setdefault("APP_ALLOW_CORS_WILDCARD", "false") diff --git a/backend/scripts/setup_test_db.sh b/backend/scripts/setup_test_db.sh new file mode 100755 index 0000000..8a52a88 --- /dev/null +++ b/backend/scripts/setup_test_db.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Setup test database for local development + +set -e + +echo "🔧 Setting up test database..." + +# Detect docker compose command (docker-compose vs docker compose) +if command -v docker-compose &> /dev/null; then + DOCKER_COMPOSE="docker-compose" +elif docker compose version &> /dev/null; then + DOCKER_COMPOSE="docker compose" +else + echo "❌ Docker Compose not found. Please install Docker and Docker Compose." + exit 1 +fi + +echo "Using: $DOCKER_COMPOSE" + +# Start test database +echo "🐳 Starting PostgreSQL test database..." +$DOCKER_COMPOSE -f docker-compose.test.yml up -d + +# Wait for database to be ready +echo "⏳ Waiting for database to be ready..." +timeout=30 +elapsed=0 +while ! $DOCKER_COMPOSE -f docker-compose.test.yml exec -T devbin_test_db pg_isready -U postgres &> /dev/null; do + if [ $elapsed -ge $timeout ]; then + echo "❌ Database failed to start within ${timeout}s" + $DOCKER_COMPOSE -f docker-compose.test.yml logs + exit 1 + fi + sleep 1 + elapsed=$((elapsed + 1)) +done + +echo "✅ Test database is ready!" +echo "📝 Connection string: postgresql://postgres:postgres@localhost:5433/devbin_test" +echo "" +echo "To run tests:" +echo " uv run pytest" +echo "" +echo "To stop test database:" +echo " $DOCKER_COMPOSE -f docker-compose.test.yml down" +echo "" +echo "To clean up test database and volumes:" +echo " $DOCKER_COMPOSE -f docker-compose.test.yml down -v" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/__init__.py b/backend/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/conftest.py b/backend/tests/api/conftest.py new file mode 100644 index 0000000..bca8e16 --- /dev/null +++ b/backend/tests/api/conftest.py @@ -0,0 +1,11 @@ +"""Fixtures for API endpoint tests.""" +import pytest_asyncio +from httpx import AsyncClient + + +@pytest_asyncio.fixture +async def authenticated_paste(test_client: AsyncClient, sample_paste_data): + """Create a paste and return it with auth tokens.""" + response = await test_client.post("/pastes", json=sample_paste_data) + assert response.status_code == 200 + return response.json() diff --git a/backend/tests/api/test_paste_routes.py b/backend/tests/api/test_paste_routes.py new file mode 100644 index 0000000..2494f3f --- /dev/null +++ b/backend/tests/api/test_paste_routes.py @@ -0,0 +1,40 @@ +"""API tests for paste endpoints.""" +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestPasteCreationAPI: + """API tests for paste creation endpoint.""" + + async def test_create_paste_returns_plaintext_tokens( + self, test_client: AsyncClient, sample_paste_data + ): + """POST /pastes should return plaintext tokens to user.""" + response = await test_client.post("/pastes", json=sample_paste_data) + + assert response.status_code == 200 + data = response.json() + + assert "id" in data + assert "edit_token" in data + assert "delete_token" in data + assert not data["edit_token"].startswith("$argon2") # Plaintext + assert not data["delete_token"].startswith("$argon2") + assert len(data["edit_token"]) == 32 # UUID hex + assert len(data["delete_token"]) == 32 + + async def test_get_paste_by_id_returns_content( + self, test_client: AsyncClient, authenticated_paste + ): + """GET /pastes/{id} should return paste content.""" + paste_id = authenticated_paste["id"] + + response = await test_client.get(f"/pastes/{paste_id}") + + assert response.status_code == 200 + data = response.json() + + assert data["id"] == paste_id + assert data["title"] == "Test Paste" + assert data["content"] == "This is test content" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..24d169c --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,130 @@ +"""Shared test fixtures for DevBin backend tests.""" +import asyncio +import os +import shutil +import tempfile +from pathlib import Path +from typing import AsyncGenerator, Generator + +# Set test environment variables BEFORE importing app modules +os.environ.setdefault("APP_DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5433/devbin_test") +os.environ.setdefault("APP_BASE_FOLDER_PATH", "/tmp/devbin_test_files") +os.environ.setdefault("APP_DEBUG", "true") +# Use a test domain instead of wildcard to avoid validator issues +os.environ.setdefault("APP_CORS_DOMAINS", '["http://test"]') +os.environ.setdefault("APP_ALLOW_CORS_WILDCARD", "false") + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from app.config import Config +from app.containers import Container +from app.db.base import Base +from main import create_app + + +@pytest.fixture(scope="session") +def event_loop(): + """Create event loop for async tests.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session") +def test_config() -> Config: + """Override config for testing.""" + import os + # Use environment variable or default to test database on port 5433 + db_url = os.getenv( + "APP_DATABASE_URL", + "postgresql+asyncpg://postgres:postgres@localhost:5433/devbin_test" + ) + + return Config( + DATABASE_URL=db_url, + BASE_FOLDER_PATH="/tmp/devbin_test_files", + DEBUG=True, + CACHE_SIZE_LIMIT=100, + CACHE_TTL=10, + ALLOW_CORS_WILDCARD=True, + CORS_DOMAINS=["*"], + ENFORCE_HTTPS=False, + MIN_STORAGE_MB=1, # Low threshold for testing + ) + + +@pytest_asyncio.fixture(scope="function") +async def test_db_engine(test_config: Config) -> AsyncGenerator[AsyncEngine, None]: + """Create test database engine and tables.""" + engine = create_async_engine(test_config.DATABASE_URL, echo=False) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + yield engine + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + await engine.dispose() + + +@pytest_asyncio.fixture(scope="function") +async def db_session(test_db_engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]: + """Create test database session with automatic rollback.""" + async_session = sessionmaker( + test_db_engine, class_=AsyncSession, expire_on_commit=False + ) + + async with async_session() as session: + yield session + await session.rollback() + + +@pytest.fixture(scope="function") +def temp_file_storage() -> Generator[Path, None, None]: + """Create temporary directory for file storage tests.""" + temp_dir = Path(tempfile.mkdtemp(prefix="devbin_test_")) + yield temp_dir + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture(scope="function") +def test_container(test_db_engine: AsyncEngine): + """Create dependency injection container with test dependencies.""" + container = Container() + + # Override engine with test engine + container.engine.override(test_db_engine) + + # Wire the container + container.wire(modules=["app.api.routes", "app.api.subroutes.pastes"]) + return container + + +@pytest_asyncio.fixture(scope="function") +async def test_client(test_container: Container) -> AsyncGenerator[AsyncClient, None]: + """Create FastAPI test client with dependency overrides.""" + app = create_app() + app.container = test_container + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test" + ) as client: + yield client + + +@pytest.fixture +def sample_paste_data(): + """Sample paste creation data.""" + return { + "title": "Test Paste", + "content": "This is test content", + "content_language": "plain_text", + } diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/security/__init__.py b/backend/tests/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_token_utils.py b/backend/tests/unit/test_token_utils.py new file mode 100644 index 0000000..bf0322f --- /dev/null +++ b/backend/tests/unit/test_token_utils.py @@ -0,0 +1,60 @@ +"""Unit tests for token utility functions.""" +import pytest + +from app.utils.token_utils import hash_token, is_token_hashed, verify_token + + +@pytest.mark.unit +class TestTokenHashing: + """Tests for Argon2 token hashing.""" + + def test_hash_token_produces_valid_argon2_hash(self): + """Hashed token should start with $argon2id$.""" + token = "my_secret_token_123" + hashed = hash_token(token) + + assert hashed.startswith("$argon2id$") + assert len(hashed) > 50 # Argon2 hashes are long + + def test_hash_token_produces_different_hashes_for_same_input(self): + """Same token should produce different hashes (salted).""" + token = "my_secret_token_123" + hash1 = hash_token(token) + hash2 = hash_token(token) + + assert hash1 != hash2 # Different due to random salt + + def test_hash_token_with_empty_string_raises_error(self): + """Empty token should raise ValueError.""" + with pytest.raises(ValueError, match="non-empty string"): + hash_token("") + + def test_verify_token_succeeds_with_correct_token(self): + """Verification should succeed with correct plaintext token.""" + plaintext = "my_secret_token_123" + hashed = hash_token(plaintext) + + assert verify_token(plaintext, hashed) is True + + def test_verify_token_fails_with_wrong_token(self): + """Verification should fail with wrong plaintext token.""" + plaintext = "my_secret_token_123" + wrong = "wrong_token" + hashed = hash_token(plaintext) + + assert verify_token(wrong, hashed) is False + + def test_verify_token_returns_false_for_empty_strings(self): + """Empty token or hash should return False.""" + assert verify_token("", "some_hash") is False + assert verify_token("some_token", "") is False + + def test_is_token_hashed_detects_argon2_format(self): + """Should detect Argon2 hashed tokens.""" + hashed = hash_token("test_token") + assert is_token_hashed(hashed) is True + + def test_is_token_hashed_returns_false_for_plaintext(self): + """Should return False for plaintext tokens.""" + plaintext = "plaintext_token_123" + assert is_token_hashed(plaintext) is False From 17fea883cfaf27bacc2fc5fc645cd3678166d14d Mon Sep 17 00:00:00 2001 From: Pdzly Date: Sat, 20 Dec 2025 17:45:10 +0100 Subject: [PATCH 3/4] feat: extend Taskfile with dev environment management commands - Add `dev:down` task to shut down all dev profiles - Add `dev:reset` task to rebuild and restart dev profiles with volume cleanup --- Taskfile.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index 6094497..28b3948 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -11,3 +11,15 @@ tasks: env: APP_DEBUG: true cmd: docker compose --profile dev_backend up -d --build + dev:down: + cmds: + - docker compose --profile dev_frontend down + - docker compose --profile dev_backend down + - docker compose --profile dev_db down + dev:reset: + cmds: + - docker compose --profile dev_frontend down + - docker compose --profile dev_backend down --volumes + - docker compose --profile dev_db down --volumes + - docker compose --profile dev_frontend up -d --build + - docker compose --profile dev_backend up -d --build \ No newline at end of file From 19f270e1fd5b073c38cadf01d74c811913d337ca Mon Sep 17 00:00:00 2001 From: Pdzly Date: Sun, 21 Dec 2025 12:43:29 +0100 Subject: [PATCH 4/4] refactor: adjust indentation across project files for consistency and readability - Standardize alignment in function parameters, method definitions, and docstrings - Correct inconsistent use of spaces in YAML files and Markdown lists - Improve readability and structure throughout backend services, tests, Alembic migrations, and configuration files --- .github/workflows/test-backend.yml | 53 +++++++++++++++++++ README.md | 16 ++++++ backend/TESTING.md | 20 ++++--- backend/Taskfile.yml | 8 ++- backend/alembic/env.py | 3 +- ...d_add_delete_edit_tokens_and_deleted_at.py | 3 +- ...updated_created_at_and_expires_at_types.py | 3 +- .../9e8ceaeba260_initial_migration.py | 2 +- backend/app/api/dto/paste_dto.py | 1 - backend/app/api/middlewares.py | 18 +++---- backend/app/api/routes.py | 4 +- backend/app/api/subroutes/pastes.py | 38 ++++++------- backend/app/config.py | 1 - backend/app/containers.py | 2 +- backend/app/db/models.py | 4 +- backend/app/dependencies/db.py | 2 +- backend/app/ratelimit.py | 2 - backend/app/services/cleanup_service.py | 6 +-- backend/app/services/health_service.py | 1 - backend/app/services/paste_service.py | 20 +++---- backend/docker-compose.test.yml | 2 +- backend/main.py | 2 - backend/pytest.ini | 1 - backend/tests/api/test_paste_routes.py | 4 +- backend/tests/conftest.py | 4 +- backend/{ => tests}/pytest_configure.py | 0 26 files changed, 143 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/test-backend.yml rename backend/{ => tests}/pytest_configure.py (100%) diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml new file mode 100644 index 0000000..e613a3f --- /dev/null +++ b/.github/workflows/test-backend.yml @@ -0,0 +1,53 @@ +name: Backend Tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: devbin_test + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Install dependencies + run: uv sync --extra test --extra migration + + - name: Run tests + env: + APP_DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5433/devbin_test + APP_BASE_FOLDER_PATH: /tmp/devbin_test_files + APP_DEBUG: "true" + APP_CORS_DOMAINS: '["http://test"]' + APP_ALLOW_CORS_WILDCARD: "false" + run: uv run pytest \ No newline at end of file diff --git a/README.md b/README.md index 1337002..ae72e49 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,22 @@ APP_RELOAD=true APP_SQLALCHEMY_ECHO=true ``` +### Tests + +### Backend Tests + +Run tests with: + +```bash +cd backend +uv run pytest +``` + +> If you modified any of the core API endpoints, make sure to run the tests to ensure they still work as expected. Or if +> you are committing breaking changes, please adjust them and add a note why this breaking change is necessary. + +See [Testing](/backend/TESTING.md) + ### API Endpoints - `GET /health` – Health check diff --git a/backend/TESTING.md b/backend/TESTING.md index df33f62..8fcf917 100644 --- a/backend/TESTING.md +++ b/backend/TESTING.md @@ -18,6 +18,7 @@ uv sync --extra test ``` This will: + - Start PostgreSQL 16 in Docker on port 5433 - Create the `devbin_test` database - Wait for the database to be ready @@ -83,10 +84,10 @@ docker-compose -f docker-compose.test.yml logs -f Tests use environment variables from `pytest.ini` by default: ```ini -APP_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5433/devbin_test -APP_BASE_FOLDER_PATH=/tmp/devbin_test_files -APP_DEBUG=true -APP_ALLOW_CORS_WILDCARD=true +APP_DATABASE_URL = postgresql+asyncpg://postgres:postgres@localhost:5433/devbin_test +APP_BASE_FOLDER_PATH = /tmp/devbin_test_files +APP_DEBUG = true +APP_ALLOW_CORS_WILDCARD = true ``` You can override these by setting environment variables before running tests: @@ -172,9 +173,9 @@ open htmlcov/index.html # or xdg-open on Linux - Overall: 80%+ (enforced in CI) - Critical modules: 90%+ - - `app/services/paste_service.py` - - `app/utils/token_utils.py` - - `app/api/subroutes/pastes.py` + - `app/services/paste_service.py` + - `app/utils/token_utils.py` + - `app/api/subroutes/pastes.py` ## Troubleshooting @@ -183,6 +184,7 @@ open htmlcov/index.html # or xdg-open on Linux **Problem**: `connection refused` or `could not connect to server` **Solution**: + 1. Check if test database is running: `docker ps | grep devbin_test` 2. Start database: `./scripts/setup_test_db.sh` 3. Check database logs: `docker-compose -f docker-compose.test.yml logs` @@ -192,6 +194,7 @@ open htmlcov/index.html # or xdg-open on Linux **Problem**: Port 5433 is already in use **Solution**: + 1. Change port in `docker-compose.test.yml` 2. Update `pytest.ini` to match 3. Restart database @@ -201,6 +204,7 @@ open htmlcov/index.html # or xdg-open on Linux **Problem**: Tests pass sometimes, fail other times (flaky tests) **Solution**: + 1. Check if tests are properly isolated (no shared state) 2. Verify database cleanup between tests 3. Check for timing issues (use `freezegun` for time-based tests) @@ -210,6 +214,7 @@ open htmlcov/index.html # or xdg-open on Linux **Problem**: Tests take too long to run **Solution**: + 1. Run tests in parallel: `pytest -n auto` 2. Run only unit tests: `pytest tests/unit/` 3. Run specific test file instead of entire suite @@ -240,6 +245,7 @@ open htmlcov/index.html # or xdg-open on Linux ## Continuous Integration Tests run automatically on: + - Push to `master` or `develop` - Pull requests diff --git a/backend/Taskfile.yml b/backend/Taskfile.yml index 75e4dad..2c2138d 100644 --- a/backend/Taskfile.yml +++ b/backend/Taskfile.yml @@ -3,6 +3,10 @@ version: '3' tasks: + api:setup_test: + cmds: + - uv sync --extra test + - ./scripts/setup_test_db.sh api:start: env: APP_DEBUG: true @@ -10,4 +14,6 @@ tasks: api:migrate: cmd: alembic upgrade head api:downmigrate: - cmd: alembic downgrade -1 \ No newline at end of file + cmd: alembic downgrade -1 + api:test: + cmd: uv run pytest \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 80d682b..62bb8f6 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,10 +1,9 @@ import os from logging.config import fileConfig +from alembic import context from sqlalchemy import engine_from_config, pool -from alembic import context -from app.db.base import Base from app.db.models import * # this is the Alembic Config object, which provides diff --git a/backend/alembic/versions/08393764144d_add_delete_edit_tokens_and_deleted_at.py b/backend/alembic/versions/08393764144d_add_delete_edit_tokens_and_deleted_at.py index a1add1a..481eb88 100644 --- a/backend/alembic/versions/08393764144d_add_delete_edit_tokens_and_deleted_at.py +++ b/backend/alembic/versions/08393764144d_add_delete_edit_tokens_and_deleted_at.py @@ -7,9 +7,8 @@ """ from typing import Sequence, Union -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. revision: str = '08393764144d' diff --git a/backend/alembic/versions/7c45e2617d61_updated_created_at_and_expires_at_types.py b/backend/alembic/versions/7c45e2617d61_updated_created_at_and_expires_at_types.py index 6c4a394..ffd8219 100644 --- a/backend/alembic/versions/7c45e2617d61_updated_created_at_and_expires_at_types.py +++ b/backend/alembic/versions/7c45e2617d61_updated_created_at_and_expires_at_types.py @@ -9,9 +9,8 @@ from typing import Sequence, Union import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - from alembic import op +from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = "7c45e2617d61" diff --git a/backend/alembic/versions/9e8ceaeba260_initial_migration.py b/backend/alembic/versions/9e8ceaeba260_initial_migration.py index 7c37f3a..b6ed19d 100644 --- a/backend/alembic/versions/9e8ceaeba260_initial_migration.py +++ b/backend/alembic/versions/9e8ceaeba260_initial_migration.py @@ -7,8 +7,8 @@ """ from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = '9e8ceaeba260' diff --git a/backend/app/api/dto/paste_dto.py b/backend/app/api/dto/paste_dto.py index a72dcdb..993e7c4 100644 --- a/backend/app/api/dto/paste_dto.py +++ b/backend/app/api/dto/paste_dto.py @@ -1,6 +1,5 @@ from datetime import datetime, timezone from enum import Enum -from uuid import UUID from pydantic import UUID4, BaseModel, Field, field_validator diff --git a/backend/app/api/middlewares.py b/backend/app/api/middlewares.py index 7deaa81..d0aedc6 100644 --- a/backend/app/api/middlewares.py +++ b/backend/app/api/middlewares.py @@ -12,17 +12,17 @@ class UserMetadataMiddleware(BaseHTTPMiddleware): def get_ip_address( - self, request: Request + self, request: Request ) -> ipaddress.IPv4Address | ipaddress.IPv6Address | Literal["unknown"]: # Try X-Forwarded-For first (proxy headers) forwarded = request.headers.get("X-Forwarded-For") ip = "unknown" if ( - forwarded - and request.client - and request.client.host - and request.client.host in config.TRUSTED_HOSTS + forwarded + and request.client + and request.client.host + and request.client.host in config.TRUSTED_HOSTS ): # Get first IP in the list (original client) split_ip = forwarded.split(",")[0].strip() @@ -121,8 +121,8 @@ async def dispatch(self, request: Request, call_next): # Check if request came over HTTPS # Note: In production behind proxy, check X-Forwarded-Proto header is_https = ( - request.url.scheme == "https" - or request.headers.get("X-Forwarded-Proto") == "https" + request.url.scheme == "https" + or request.headers.get("X-Forwarded-Proto") == "https" ) if is_https: @@ -145,8 +145,8 @@ class HTTPSRedirectMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # Check if request is over HTTPS is_https = ( - request.url.scheme == "https" - or request.headers.get("X-Forwarded-Proto") == "https" + request.url.scheme == "https" + or request.headers.get("X-Forwarded-Proto") == "https" ) if not is_https: diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 6dfb651..6cc11b2 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,9 +1,7 @@ from __future__ import annotations -from typing import Any - -from fastapi import APIRouter, Depends from dependency_injector.wiring import Provide, inject +from fastapi import APIRouter, Depends from starlette.requests import Request from app.api.subroutes.pastes import pastes_route diff --git a/backend/app/api/subroutes/pastes.py b/backend/app/api/subroutes/pastes.py index 52a3a4e..3a090d9 100644 --- a/backend/app/api/subroutes/pastes.py +++ b/backend/app/api/subroutes/pastes.py @@ -2,7 +2,7 @@ from aiocache.serializers import PickleSerializer from dependency_injector.wiring import Provide, inject -from fastapi import APIRouter, Depends, Header, HTTPException +from fastapi import APIRouter, Depends, HTTPException from fastapi.params import Security from fastapi.security import APIKeyHeader from pydantic import UUID4 @@ -48,9 +48,9 @@ def get_exempt_key(request: Request) -> str: @limiter.limit("10/minute", key_func=get_exempt_key) @inject async def get_paste( - request: Request, - paste_id: str, - paste_service: PasteService = Depends(Provide[Container.paste_service]), + request: Request, + paste_id: str, + paste_service: PasteService = Depends(Provide[Container.paste_service]), ): cached_result = await cache.get(paste_id) if cached_result: @@ -95,9 +95,9 @@ async def get_paste( @limiter.limit("10/minute", key_func=get_exempt_key) @inject async def get_paste( - request: Request, - paste_id: UUID4, - paste_service: PasteService = Depends(Provide[Container.paste_service]), + request: Request, + paste_id: UUID4, + paste_service: PasteService = Depends(Provide[Container.paste_service]), ): cached_result = await cache.get(paste_id) if cached_result: @@ -138,9 +138,9 @@ async def get_paste( @limiter.limit("4/minute", key_func=get_exempt_key) @inject async def create_paste( - request: Request, - create_paste_body: CreatePaste, - paste_service: PasteService = Depends(Provide[Container.paste_service]), + request: Request, + create_paste_body: CreatePaste, + paste_service: PasteService = Depends(Provide[Container.paste_service]), ): return await paste_service.create_paste( create_paste_body, request.state.user_metadata @@ -151,11 +151,11 @@ async def create_paste( @limiter.limit("4/minute", key_func=get_exempt_key) @inject async def edit_paste( - request: Request, - paste_id: UUID4, - edit_paste_body: EditPaste, - edit_token: str = Security(edit_token_key_header), - paste_service: PasteService = Depends(Provide[Container.paste_service]), + request: Request, + paste_id: UUID4, + edit_paste_body: EditPaste, + edit_token: str = Security(edit_token_key_header), + paste_service: PasteService = Depends(Provide[Container.paste_service]), ): result = await paste_service.edit_paste(paste_id, edit_paste_body, edit_token) if not result: @@ -173,10 +173,10 @@ async def edit_paste( @limiter.limit("4/minute", key_func=get_exempt_key) @inject async def delete_paste( - request: Request, - paste_id: UUID4, - delete_token: str = Security(delete_token_key_header), - paste_service: PasteService = Depends(Provide[Container.paste_service]), + request: Request, + paste_id: UUID4, + delete_token: str = Security(delete_token_key_header), + paste_service: PasteService = Depends(Provide[Container.paste_service]), ): result = await paste_service.delete_paste(paste_id, delete_token) if not result: diff --git a/backend/app/config.py b/backend/app/config.py index 38145e1..6fbbff2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,3 @@ -import ipaddress import logging from typing import Literal diff --git a/backend/app/containers.py b/backend/app/containers.py index 46605e1..f69829c 100644 --- a/backend/app/containers.py +++ b/backend/app/containers.py @@ -12,7 +12,7 @@ @asynccontextmanager async def _engine_resource( - db_url: str, echo: bool = False + db_url: str, echo: bool = False ) -> AsyncIterator[AsyncEngine]: engine = create_async_engine(db_url, echo=echo, future=True) try: diff --git a/backend/app/db/models.py b/backend/app/db/models.py index d94845f..a61bea0 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,6 +1,4 @@ -import uuid - -from sqlalchemy import TIMESTAMP, UUID, Column, DateTime, Integer, String, func, text +from sqlalchemy import TIMESTAMP, UUID, Column, Integer, String, func, text from app.db.base import Base diff --git a/backend/app/dependencies/db.py b/backend/app/dependencies/db.py index 04f8e3f..b06f8b5 100644 --- a/backend/app/dependencies/db.py +++ b/backend/app/dependencies/db.py @@ -21,7 +21,7 @@ async def _session_scope(factory: sessionmaker) -> AsyncIterator[AsyncSession]: @inject async def get_session( - factory: sessionmaker = Provide[Container.session_factory], + factory: sessionmaker = Provide[Container.session_factory], ) -> AsyncGenerator[AsyncSession, None]: async with _session_scope(factory) as session: yield session diff --git a/backend/app/ratelimit.py b/backend/app/ratelimit.py index 5474bd5..5f2cc59 100644 --- a/backend/app/ratelimit.py +++ b/backend/app/ratelimit.py @@ -1,5 +1,3 @@ -import ipaddress - from slowapi import Limiter from starlette.requests import Request diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index ebb644c..12f8608 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -14,9 +14,9 @@ class CleanupService: def __init__( - self, - session_maker: sessionmaker[AsyncSession], # pyright: ignore[reportInvalidTypeArguments] - paste_base_folder_path: str = "", + self, + session_maker: sessionmaker[AsyncSession], # pyright: ignore[reportInvalidTypeArguments] + paste_base_folder_path: str = "", ): self.session_maker: sessionmaker[AsyncSession] = session_maker self.paste_base_folder_path: str = paste_base_folder_path diff --git a/backend/app/services/health_service.py b/backend/app/services/health_service.py index 952de36..92a2b45 100644 --- a/backend/app/services/health_service.py +++ b/backend/app/services/health_service.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -from typing import Any from fastapi.responses import ORJSONResponse from sqlalchemy import text diff --git a/backend/app/services/paste_service.py b/backend/app/services/paste_service.py index 7023b33..7dd4c4f 100644 --- a/backend/app/services/paste_service.py +++ b/backend/app/services/paste_service.py @@ -14,7 +14,7 @@ from aiofiles import os from fastapi import HTTPException from pydantic import UUID4 -from sqlalchemy import delete, or_, select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio.session import AsyncSession from sqlalchemy.orm import sessionmaker @@ -35,10 +35,10 @@ class PasteService: def __init__( - self, - session: sessionmaker[AsyncSession], - cleanup_service: CleanupService, - paste_base_folder_path: str = "", + self, + session: sessionmaker[AsyncSession], + cleanup_service: CleanupService, + paste_base_folder_path: str = "", ): self.session_maker: sessionmaker[AsyncSession] = session self.paste_base_folder_path: str = ( @@ -98,10 +98,10 @@ def verify_storage_limit(self): return True async def get_legacy_paste_by_name( - self, paste_id: str + self, paste_id: str ) -> LegacyPasteResponse | None: if not (await os.path.exists(self.paste_base_folder_path)) or not ( - await os.path.isdir(path.join(self.paste_base_folder_path, "hastebin")) + await os.path.isdir(path.join(self.paste_base_folder_path, "hastebin")) ): return None paste_md5: str = hashlib.md5(paste_id.encode()).hexdigest() @@ -148,7 +148,7 @@ async def get_paste_by_id(self, paste_id: UUID4) -> PasteResponse | None: ) async def edit_paste( - self, paste_id: UUID4, edit_paste: EditPaste, edit_token: str + self, paste_id: UUID4, edit_paste: EditPaste, edit_token: str ) -> PasteResponse | None: async with self.session_maker() as session: stmt = ( @@ -189,7 +189,7 @@ async def edit_paste( # Update only the fields that are provided (not None) if ( - edit_paste.title is not None + edit_paste.title is not None ): # Using ellipsis as sentinel for "not provided" result.title = edit_paste.title if edit_paste.content_language is not None: @@ -276,7 +276,7 @@ async def delete_paste(self, paste_id: UUID4, delete_token: str) -> bool: return True async def create_paste( - self, paste: CreatePaste, user_data: UserMetaData + self, paste: CreatePaste, user_data: UserMetaData ) -> PasteResponse: if not self.verify_storage_limit(): raise HTTPException( diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml index 5543cdc..553abfd 100644 --- a/backend/docker-compose.test.yml +++ b/backend/docker-compose.test.yml @@ -11,7 +11,7 @@ services: ports: - "5433:5432" # Use different port to avoid conflicts healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: [ "CMD-SHELL", "pg_isready -U postgres" ] interval: 5s timeout: 5s retries: 5 diff --git a/backend/main.py b/backend/main.py index 9ff4e47..5215f48 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import logging import os from contextlib import asynccontextmanager @@ -21,7 +20,6 @@ from app.containers import Container from app.ratelimit import limiter from app.services.cleanup_service import CleanupService -from app.services.paste_service import PasteService # Set the custom encoder diff --git a/backend/pytest.ini b/backend/pytest.ini index 4aa6814..0db86ea 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -4,7 +4,6 @@ testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* -plugins = pytest_configure addopts = -v --strict-markers diff --git a/backend/tests/api/test_paste_routes.py b/backend/tests/api/test_paste_routes.py index 2494f3f..5a8d381 100644 --- a/backend/tests/api/test_paste_routes.py +++ b/backend/tests/api/test_paste_routes.py @@ -8,7 +8,7 @@ class TestPasteCreationAPI: """API tests for paste creation endpoint.""" async def test_create_paste_returns_plaintext_tokens( - self, test_client: AsyncClient, sample_paste_data + self, test_client: AsyncClient, sample_paste_data ): """POST /pastes should return plaintext tokens to user.""" response = await test_client.post("/pastes", json=sample_paste_data) @@ -25,7 +25,7 @@ async def test_create_paste_returns_plaintext_tokens( assert len(data["delete_token"]) == 32 async def test_get_paste_by_id_returns_content( - self, test_client: AsyncClient, authenticated_paste + self, test_client: AsyncClient, authenticated_paste ): """GET /pastes/{id} should return paste content.""" paste_id = authenticated_paste["id"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 24d169c..2614acc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -114,8 +114,8 @@ async def test_client(test_container: Container) -> AsyncGenerator[AsyncClient, app.container = test_container async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test" + transport=ASGITransport(app=app), + base_url="http://test" ) as client: yield client diff --git a/backend/pytest_configure.py b/backend/tests/pytest_configure.py similarity index 100% rename from backend/pytest_configure.py rename to backend/tests/pytest_configure.py