From 4abb9f98d52a0fadfa6330027a32ec61ae85944d Mon Sep 17 00:00:00 2001 From: ddc Date: Thu, 19 Feb 2026 15:02:03 -0300 Subject: [PATCH 01/13] v3.0.4 --- .github/workflows/workflow.yml | 2 ++ .hadolint.yaml => .hadolint.yml | 2 ++ docker-compose.yml | 4 ++-- pyproject.toml | 6 +++--- tests/docker/test_docker.py | 2 +- uv.lock | 38 ++++++++++++++++----------------- 6 files changed, 29 insertions(+), 25 deletions(-) rename .hadolint.yaml => .hadolint.yml (72%) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 634f67c3..72ac2262 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -86,6 +86,8 @@ jobs: - name: Lint Dockerfile uses: hadolint/hadolint-action@v3.1.0 + with: + config: .hadolint.yml - name: Validate compose file run: | diff --git a/.hadolint.yaml b/.hadolint.yml similarity index 72% rename from .hadolint.yaml rename to .hadolint.yml index d0d1f79c..26a7f35e 100644 --- a/.hadolint.yaml +++ b/.hadolint.yml @@ -1,7 +1,9 @@ ignored: + - DL3003 # Use WORKDIR to switch to a directory (cd is correct inside RUN for source builds) - DL3013 # Pin versions in pip install (we use uv, not pip) - DL3018 # Pin versions in apk add (Alpine packages aren't version-pinnable) - DL3044 # Env var reference in same ENV where defined (intentional for PYTHONPATH) - DL3048 # Invalid label key (we use Description, not OCI labels) - DL4006 # Set SHELL pipefail (Alpine uses ash, not bash) + - SC2046 # Quote to prevent word splitting (nproc returns a single number) - SC2086 # Double quote variables (not needed in controlled Dockerfile context) diff --git a/docker-compose.yml b/docker-compose.yml index 03c979bd..db354c35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: DOCKER_BUILDKIT: 1 networks: - postgres_network - command: ["sh", "-c", "uv run --frozen alembic upgrade head && touch /tmp/alembic_done && sleep infinity"] + command: ["sh", "-c", "uv run --frozen --no-sync alembic upgrade head && touch /tmp/alembic_done && sleep infinity"] deploy: restart_policy: delay: 60s @@ -40,7 +40,7 @@ services: deploy: restart_policy: delay: 60s - command: ["uv", "run", "--frozen", "python", "-m", "src"] + command: ["uv", "run", "--frozen", "--no-sync", "python", "-m", "src"] networks: postgres_network: diff --git a/pyproject.toml b/pyproject.toml index 4f8116dc..9fd1ffd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "DiscordBot" -version = "3.0.3" +version = "3.0.4" description = "A simple Discord bot with OpenAI support and server administration tools" urls.Repository = "https://github.com/ddc/DiscordBot" urls.Homepage = "https://github.com/ddc/DiscordBot" @@ -56,10 +56,10 @@ profile = "uv run python -m cProfile -o cprofile_unit.prof -m pytest tests/unit profile-integration = "uv run python -m cProfile -o cprofile_integration.prof -m pytest tests/integration --no-cov" test = "uv run pytest tests/unit" test-integration = "uv run pytest tests/integration --no-cov" -hadolint.shell = "docker run --rm -i -v $(pwd)/.hadolint.yaml:/.config/hadolint.yaml:ro hadolint/hadolint < Dockerfile" +hadolint.shell = "docker run --rm -i -v $(pwd)/.hadolint.yml:/.config/hadolint.yml:ro hadolint/hadolint < Dockerfile" test-docker = "uv run pytest tests/docker -v --no-cov" migration = "uv run --frozen alembic upgrade head" -tests.sequence = ["test", "test-integration", "hadolint", "test-docker"] +tests.sequence = ["linter", "hadolint", "test-docker", "test", "test-integration"] updatedev.sequence = ["linter", {shell = "uv lock --upgrade && uv sync --all-extras --group dev"}] [tool.pytest.ini_options] diff --git a/tests/docker/test_docker.py b/tests/docker/test_docker.py index 0b7b2485..38a922d4 100644 --- a/tests/docker/test_docker.py +++ b/tests/docker/test_docker.py @@ -9,7 +9,7 @@ class TestDockerLint: def test_hadolint_dockerfile(self, project_root): """Dockerfile passes hadolint linting.""" dockerfile = project_root / "Dockerfile" - hadolint_config = project_root / ".hadolint.yaml" + hadolint_config = project_root / ".hadolint.yml" cmd = ["docker", "run", "--rm", "-i"] if hadolint_config.exists(): cmd += ["-v", f"{hadolint_config}:/.config/hadolint.yaml:ro"] diff --git a/uv.lock b/uv.lock index be97bef6..6d94de92 100644 --- a/uv.lock +++ b/uv.lock @@ -365,7 +365,7 @@ wheels = [ [[package]] name = "discordbot" -version = "3.0.3" +version = "3.0.4" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -787,14 +787,14 @@ wheels = [ [[package]] name = "psycopg" -version = "3.3.2" +version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, ] [package.optional-dependencies] @@ -804,20 +804,20 @@ binary = [ [[package]] name = "psycopg-binary" -version = "3.3.2" +version = "3.3.3" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" }, - { url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" }, - { url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" }, - { url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" }, - { url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" }, - { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, ] [[package]] @@ -885,16 +885,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/a1/ae859ffac5a3338a66b74c5e29e244fd3a3cc483c89feaf9f56c39898d75/pydantic_settings-2.13.0.tar.gz", hash = "sha256:95d875514610e8595672800a5c40b073e99e4aae467fa7c8f9c263061ea2e1fe", size = 222450, upload-time = "2026-02-15T12:11:23.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/1a/dd1b9d7e627486cf8e7523d09b70010e05a4bc41414f4ae6ce184cf0afb6/pydantic_settings-2.13.0-py3-none-any.whl", hash = "sha256:d67b576fff39cd086b595441bf9c75d4193ca9c0ed643b90360694d0f1240246", size = 58429, upload-time = "2026-02-15T12:11:22.133Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] From 9f5d1c96f8054a117ce9b34fe4d787f2f00af815 Mon Sep 17 00:00:00 2001 From: ddc Date: Sun, 22 Feb 2026 13:39:14 -0300 Subject: [PATCH 02/13] v3.0.4 --- pyproject.toml | 6 ++-- uv.lock | 94 +++++++++++++++++++++++++------------------------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9fd1ffd8..13f450e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ urls.Homepage = "https://github.com/ddc/DiscordBot" license = { text = "MIT" } readme = "README.md" authors = [ - { name = "Daniel Costa", email = "ddcsoftwares@proton.me" }, + { name = "Daniel Costa", email = "daniel@ddcsoftwares.com" }, ] maintainers = [ { name = "Daniel Costa" }, @@ -46,8 +46,8 @@ dev = [ "pytest-asyncio>=1.3.0", "pytest-cov>=7.0.0", "testcontainers[postgres]>=4.14.1", - "poethepoet>=0.41.0", - "ruff>=0.15.1", + "poethepoet>=0.42.0", + "ruff>=0.15.2", ] [tool.poe.tasks] diff --git a/uv.lock b/uv.lock index 6d94de92..5a27651f 100644 --- a/uv.lock +++ b/uv.lock @@ -403,10 +403,10 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "poethepoet", specifier = ">=0.41.0" }, + { name = "poethepoet", specifier = ">=0.42.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, - { name = "ruff", specifier = ">=0.15.1" }, + { name = "ruff", specifier = ">=0.15.2" }, { name = "testcontainers", extras = ["postgres"], specifier = ">=4.14.1" }, ] @@ -476,27 +476,27 @@ wheels = [ [[package]] name = "greenlet" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, - { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, - { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, - { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] [[package]] @@ -735,15 +735,15 @@ wheels = [ [[package]] name = "poethepoet" -version = "0.41.0" +version = "0.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pastel" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/b9/fa92286560f70eaa40d473ea48376d20c6c21f63627d33c6bb1c5e385175/poethepoet-0.41.0.tar.gz", hash = "sha256:dcaad621dc061f6a90b17d091bebb9ca043d67bfe9bd6aa4185aea3ebf7ff3e6", size = 87780, upload-time = "2026-02-08T20:45:36.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/9a/4e81fafef2ba94e5c974b4701343d1f053a27575ab5133cbd264348925dd/poethepoet-0.42.0.tar.gz", hash = "sha256:c9a2828259e585e9ed152857602130ff339f7b1638879b80d4a23f25588be4f8", size = 91278, upload-time = "2026-02-22T14:24:50.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/5e/0b83e0222ce5921b3f9081eeca8c6fb3e1cfd5ca0d06338adf93b28ce061/poethepoet-0.41.0-py3-none-any.whl", hash = "sha256:4bab9fd8271664c5d21407e8f12827daeb6aa484dc6cc7620f0c3b4e62b42ee4", size = 113590, upload-time = "2026-02-08T20:45:34.697Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/58041b7e4d49b69e859dc81c35e221cf02d91ed4dbb5a2f6cc4698a29f44/poethepoet-0.42.0-py3-none-any.whl", hash = "sha256:e43cc20d458ee5bfccaa4572bc5783bcb93991a7d2fcf8dadc9c43f1ebc9b277", size = 118091, upload-time = "2026-02-22T14:24:49.53Z" }, ] [[package]] @@ -1057,27 +1057,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, - { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, - { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, - { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, - { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, - { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, - { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, - { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, - { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, - { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, ] [[package]] From d06321e243980b22a9d1058b8ea424e9d9da0f4e Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:32:24 -0300 Subject: [PATCH 03/13] v3.0.4 --- src/database/dal/gw2/gw2_sessions_dal.py | 8 +++++++ src/gw2/tools/gw2_utils.py | 3 +++ tests/integration/test_gw2_sessions_dal.py | 12 +++++++++-- tests/unit/database/test_dal.py | 25 +++++++++++++++++++--- tests/unit/gw2/tools/test_gw2_utils.py | 25 ++++++++++++++++++++++ utilities/stop.sh | 9 ++++++++ 6 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 utilities/stop.sh diff --git a/src/database/dal/gw2/gw2_sessions_dal.py b/src/database/dal/gw2/gw2_sessions_dal.py index 4334eb41..6a765ee2 100644 --- a/src/database/dal/gw2/gw2_sessions_dal.py +++ b/src/database/dal/gw2/gw2_sessions_dal.py @@ -30,6 +30,13 @@ async def insert_start_session(self, session: dict): return stmt.id async def update_end_session(self, session: dict): + results = await self.db_utils.fetchall( + select(Gw2Sessions.id).where(Gw2Sessions.user_id == session["user_id"]), + True, + ) + if not results: + return None + stmt = ( sa.update(Gw2Sessions) .where( @@ -38,6 +45,7 @@ async def update_end_session(self, session: dict): .values(end=session) ) await self.db_utils.execute(stmt) + return results[0]["id"] async def get_user_last_session(self, user_id: int): stmt = select(*self.columns).where(Gw2Sessions.user_id == user_id) diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index 50315d33..0ea0a3bb 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -342,6 +342,9 @@ async def end_session(bot: Bot, member: discord.Member, api_key: str) -> None: gw2_session_dal = Gw2SessionsDal(bot.db_session, bot.log) session_id = await gw2_session_dal.update_end_session(session) + if session_id is None: + bot.log.warning(f"No active session found for user {member.id}, skipping end session chars") + return await insert_session_char(bot, member, api_key, session_id, "end") diff --git a/tests/integration/test_gw2_sessions_dal.py b/tests/integration/test_gw2_sessions_dal.py index 912ab590..d037a035 100644 --- a/tests/integration/test_gw2_sessions_dal.py +++ b/tests/integration/test_gw2_sessions_dal.py @@ -36,14 +36,22 @@ async def test_insert_start_session_stores_jsonb(db_session, log): async def test_update_end_session(db_session, log): dal = Gw2SessionsDal(db_session, log) - await dal.insert_start_session(_make_session()) + start_id = await dal.insert_start_session(_make_session()) end_data = {"user_id": USER_ID, "gold": 200, "karma": 6000} - await dal.update_end_session(end_data) + end_id = await dal.update_end_session(end_data) + assert end_id == start_id results = await dal.get_user_last_session(USER_ID) assert results[0]["end"]["gold"] == 200 assert results[0]["end"]["karma"] == 6000 +async def test_update_end_session_no_session(db_session, log): + dal = Gw2SessionsDal(db_session, log) + end_data = {"user_id": 999999, "gold": 200} + result = await dal.update_end_session(end_data) + assert result is None + + async def test_insert_start_session_cleans_old_data(db_session, log): dal = Gw2SessionsDal(db_session, log) first_session = _make_session() diff --git a/tests/unit/database/test_dal.py b/tests/unit/database/test_dal.py index 64e59686..2134e883 100644 --- a/tests/unit/database/test_dal.py +++ b/tests/unit/database/test_dal.py @@ -1120,23 +1120,42 @@ async def test_insert_start_session_different_user(self, mock_dal): @pytest.mark.asyncio async def test_update_end_session(self, mock_dal): - """Test update_end_session calls execute.""" + """Test update_end_session fetches session id then calls execute.""" + mock_dal.db_utils.fetchall.return_value = [{"id": 42}] session = { "user_id": 67890, "gold": 150, } - await mock_dal.update_end_session(session) + result = await mock_dal.update_end_session(session) + mock_dal.db_utils.fetchall.assert_called_once() mock_dal.db_utils.execute.assert_called_once() + assert result == 42 @pytest.mark.asyncio async def test_update_end_session_different_user(self, mock_dal): """Test update_end_session with different user.""" + mock_dal.db_utils.fetchall.return_value = [{"id": 99}] session = { "user_id": 11111, "gold": 75, } - await mock_dal.update_end_session(session) + result = await mock_dal.update_end_session(session) + mock_dal.db_utils.fetchall.assert_called_once() mock_dal.db_utils.execute.assert_called_once() + assert result == 99 + + @pytest.mark.asyncio + async def test_update_end_session_no_session_found(self, mock_dal): + """Test update_end_session returns None when no session exists.""" + mock_dal.db_utils.fetchall.return_value = [] + session = { + "user_id": 67890, + "gold": 150, + } + result = await mock_dal.update_end_session(session) + mock_dal.db_utils.fetchall.assert_called_once() + mock_dal.db_utils.execute.assert_not_called() + assert result is None @pytest.mark.asyncio async def test_get_user_last_session(self, mock_dal): diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index e4274c2e..58961b96 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -882,6 +882,31 @@ async def test_successful_end_session(self, mock_bot, mock_member): mock_insert_char.assert_called_once_with(mock_bot, mock_member, "api-key", 42, "end") + @pytest.mark.asyncio + async def test_end_session_no_active_session(self, mock_bot, mock_member): + """Test end_session skips insert_session_char when no active session exists.""" + session_data = {"acc_name": "TestUser.1234", "wvw_rank": 50, "gold": 1000} + + with patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats: + mock_stats.return_value = session_data.copy() + + with patch("src.gw2.tools.gw2_utils.bot_utils.convert_datetime_to_str_short") as mock_convert: + mock_convert.return_value = "2023-01-01" + + with patch("src.gw2.tools.gw2_utils.bot_utils.get_current_date_time") as mock_time: + mock_time.return_value = datetime(2023, 1, 1, 12, 0, 0) + + with patch("src.gw2.tools.gw2_utils.Gw2SessionsDal") as mock_session_dal: + mock_instance = mock_session_dal.return_value + mock_instance.update_end_session = AsyncMock(return_value=None) + + with patch("src.gw2.tools.gw2_utils.insert_session_char") as mock_insert_char: + await end_session(mock_bot, mock_member, "api-key") + + mock_instance.update_end_session.assert_called_once() + mock_insert_char.assert_not_called() + mock_bot.log.warning.assert_called_once() + class TestGetUserStats: """Test cases for get_user_stats function.""" diff --git a/utilities/stop.sh b/utilities/stop.sh new file mode 100644 index 00000000..b55cf85f --- /dev/null +++ b/utilities/stop.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR=/opt/DiscordBot +pushd "$PROJECT_DIR" > /dev/null + +docker compose down + +popd > /dev/null From 1b3f3d5755393e22e31131c2f3188eb9967320ea Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:35:00 -0300 Subject: [PATCH 04/13] v3.0.4 --- utilities/update.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utilities/update.sh b/utilities/update.sh index 33c79938..ba0c2563 100644 --- a/utilities/update.sh +++ b/utilities/update.sh @@ -7,8 +7,8 @@ PROJECT_DIR=/opt/DiscordBot pushd "$PROJECT_DIR" > /dev/null # update project -git fetch --all -git reset --hard origin/master +sudo git fetch --all +sudo git reset --hard origin/master # change perms sudo chown -R "$PROJECT_USERNAME":"$PROJECT_USERNAME" "$PROJECT_DIR" From 7312e96defc3f9d688b674e8da54f8b704780020 Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:38:18 -0300 Subject: [PATCH 05/13] v3.0.4 --- utilities/update.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utilities/update.sh b/utilities/update.sh index ba0c2563..44cac632 100644 --- a/utilities/update.sh +++ b/utilities/update.sh @@ -18,6 +18,6 @@ sudo chmod 600 "$PROJECT_DIR/.env" sudo chmod 755 "$PROJECT_DIR/utilities"/*.sh # ensure logs dir is writable by container's botuser (uid 1000) -sudo chown 1000:1000 "$PROJECT_DIR/logs" +sudo chown -R 1000:1000 "$PROJECT_DIR/logs" popd > /dev/null From c33ca38f9e1ce9b8604d6e2c7db0f12dfdb734a8 Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:54:03 -0300 Subject: [PATCH 06/13] v3.0.4 --- src/bot/cogs/events/on_command_error.py | 9 +++++++-- src/gw2/cogs/config.py | 1 + src/gw2/cogs/sessions.py | 5 +++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/bot/cogs/events/on_command_error.py b/src/bot/cogs/events/on_command_error.py index 8bcf0a4f..4f5c2b23 100644 --- a/src/bot/cogs/events/on_command_error.py +++ b/src/bot/cogs/events/on_command_error.py @@ -154,7 +154,9 @@ async def on_command_error(ctx: commands.Context, error: Exception) -> None: await handler(context, should_log) @staticmethod - async def _send_error_message(ctx: commands.Context, error_msg: str, should_log: bool) -> None: + async def _send_error_message( + ctx: commands.Context, error_msg: str, should_log: bool, original_error: Exception | None = None + ) -> None: """Send an error message to user and optionally log it.""" await bot_utils.send_error_msg(ctx, error_msg) if should_log: @@ -162,6 +164,8 @@ async def _send_error_message(ctx: commands.Context, error_msg: str, should_log: if ctx.guild is not None: log_msg += f"(Server[{ctx.guild.name}:{ctx.guild.id}])" log_msg += f"(Channel[{ctx.message.channel}:{ctx.message.channel.id}])" + if original_error is not None: + log_msg += f"(Error[{original_error}])" ctx.bot.log.error(log_msg) async def _handle_no_private_message(self, context: ErrorContext, should_log: bool) -> None: @@ -206,7 +210,8 @@ async def _handle_command_error(self, context: ErrorContext, should_log: bool) - async def _handle_command_invoke_error(self, context: ErrorContext, should_log: bool) -> None: """Handle CommandInvokeError.""" error_msg = self.message_builder.build_command_invoke_error(context) - await self._send_error_message(context.ctx, error_msg, should_log) + original = getattr(context.error, "original", context.error) + await self._send_error_message(context.ctx, error_msg, should_log, original) async def _handle_command_on_cooldown(self, context: ErrorContext, should_log: bool) -> None: """Handle CommandOnCooldown error.""" diff --git a/src/gw2/cogs/config.py b/src/gw2/cogs/config.py index 68f22b4f..61dd3fd4 100644 --- a/src/gw2/cogs/config.py +++ b/src/gw2/cogs/config.py @@ -16,6 +16,7 @@ def __init__(self, bot): @GuildWars2.gw2.group() +@commands.guild_only() @Checks.check_is_admin() async def config(ctx): """Guild Wars 2 server configuration commands. diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index 41a6d8d1..7faf41f2 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -19,6 +19,7 @@ def __init__(self, bot): @GW2Session.gw2.command() +@commands.guild_only() @commands.cooldown(1, GW2CoolDowns.Session.seconds, commands.BucketType.user) async def session(ctx): """Display information about your last Guild Wars 2 game session. @@ -87,7 +88,7 @@ async def session(ctx): rs_start = rs_session[0]["start"] rs_end = rs_session[0]["end"] - if rs_end["date"] is None: + if rs_end is None or rs_end.get("date") is None: return await bot_utils.send_error_msg(ctx, gw2_messages.SESSION_SAVE_ERROR, True) await ctx.message.channel.typing() @@ -108,7 +109,7 @@ async def session(ctx): embed = discord.Embed(color=color) embed.set_author( name=f"{ctx.message.author.display_name}'s {gw2_messages.SESSION_TITLE} ({rs_start['date'].split()[0]})", - icon_url=ctx.message.author.avatar.url, + icon_url=ctx.message.author.display_avatar.url, ) embed.add_field(name=gw2_messages.ACCOUNT_NAME, value=chat_formatting.inline(acc_name)) embed.add_field(name=gw2_messages.SERVER, value=chat_formatting.inline(gw2_server)) From b8e3b99fde81475269c704951ceed2e4df39a78f Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:02:09 -0300 Subject: [PATCH 07/13] v3.0.4 --- src/bot/cogs/events/on_command_error.py | 3 +-- src/gw2/cogs/sessions.py | 4 ++-- tests/unit/bot/events/test_on_command_error.py | 8 +++++--- tests/unit/gw2/cogs/test_sessions.py | 16 ++++++++-------- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/bot/cogs/events/on_command_error.py b/src/bot/cogs/events/on_command_error.py index 4f5c2b23..dac62032 100644 --- a/src/bot/cogs/events/on_command_error.py +++ b/src/bot/cogs/events/on_command_error.py @@ -93,7 +93,6 @@ def build_command_invoke_error(context: ErrorContext) -> str: """Build message for command invoke error.""" error_conditions = { ("Cannot send messages to this user", "status code: 403"): messages.DIRECT_MESSAGES_DISABLED, - ("AttributeError",): f"{messages.COMMAND_ERROR}: `{context.command}`", ("Missing Permissions",): f"{messages.NO_PERMISSION_EXECUTE_COMMAND}: `{context.command}`", ( "NoOptionError", @@ -111,7 +110,7 @@ def build_command_invoke_error(context: ErrorContext) -> str: base_msg = message break else: - base_msg = f"{messages.COMMAND_INTERNAL_ERROR}: `{context.command}`" + base_msg = f"{messages.COMMAND_ERROR}: `{context.command}`\n{context.error_msg}" return f"{base_msg}\n{messages.HELP_COMMAND_MORE_INFO}: `{context.help_command}`" diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index 7faf41f2..efc539ca 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -137,8 +137,8 @@ async def session(ctx): prof_names = "" total_deaths = 0 - for _, char_start in rs_chars_start.items(): - for _, char_end in rs_chars_end.items(): + for char_start in rs_chars_start: + for char_end in rs_chars_end: if char_start["name"] == char_end["name"]: if char_start["deaths"] != char_end["deaths"]: name = char_start["name"] diff --git a/tests/unit/bot/events/test_on_command_error.py b/tests/unit/bot/events/test_on_command_error.py index 0a0a8a19..25c18c38 100644 --- a/tests/unit/bot/events/test_on_command_error.py +++ b/tests/unit/bot/events/test_on_command_error.py @@ -434,7 +434,8 @@ def test_build_command_invoke_error_no_matching_condition(self, mock_ctx, mock_e context.error_msg = "Some completely unrelated error that matches nothing" result = ErrorMessageBuilder.build_command_invoke_error(context) - assert f"{messages.COMMAND_INTERNAL_ERROR}: `!testcommand`" in result + assert f"{messages.COMMAND_ERROR}: `!testcommand`" in result + assert "Some completely unrelated error that matches nothing" in result assert f"{messages.HELP_COMMAND_MORE_INFO}: `!help testcommand`" in result def test_build_command_invoke_error_attribute_error(self, mock_ctx, mock_error): @@ -444,6 +445,7 @@ def test_build_command_invoke_error_attribute_error(self, mock_ctx, mock_error): result = ErrorMessageBuilder.build_command_invoke_error(context) assert f"{messages.COMMAND_ERROR}: `!testcommand`" in result + assert "AttributeError" in result def test_build_command_invoke_error_missing_permissions(self, mock_ctx, mock_error): """Test build_command_invoke_error with Missing Permissions.""" @@ -581,7 +583,7 @@ async def test_on_command_error_command_invoke_error(self, mock_send_error, mock mock_send_error.assert_called_once() call_msg = mock_send_error.call_args[0][1] - assert messages.COMMAND_INTERNAL_ERROR in call_msg + assert messages.COMMAND_ERROR in call_msg @pytest.mark.asyncio @patch("src.bot.cogs.events.on_command_error.bot_utils.send_error_msg") @@ -770,7 +772,7 @@ async def test_handle_command_invoke_error(self, mock_send_error, errors_cog, mo mock_send_error.assert_called_once() call_args = mock_send_error.call_args[0] assert call_args[0] == mock_ctx - assert messages.COMMAND_INTERNAL_ERROR in call_args[1] + assert messages.COMMAND_ERROR in call_args[1] assert call_args[2] is True diff --git a/tests/unit/gw2/cogs/test_sessions.py b/tests/unit/gw2/cogs/test_sessions.py index b28f2a83..85647280 100644 --- a/tests/unit/gw2/cogs/test_sessions.py +++ b/tests/unit/gw2/cogs/test_sessions.py @@ -625,14 +625,14 @@ async def test_session_characters_with_deaths(self, mock_ctx, sample_api_key_dat } ] - chars_start = { - "char1": {"name": "TestChar", "profession": "Warrior", "deaths": 10}, - "char2": {"name": "TestChar2", "profession": "Ranger", "deaths": 5}, - } - chars_end = { - "char1": {"name": "TestChar", "profession": "Warrior", "deaths": 15}, - "char2": {"name": "TestChar2", "profession": "Ranger", "deaths": 5}, # No change - } + chars_start = [ + {"name": "TestChar", "profession": "Warrior", "deaths": 10}, + {"name": "TestChar2", "profession": "Ranger", "deaths": 5}, + ] + chars_end = [ + {"name": "TestChar", "profession": "Warrior", "deaths": 15}, + {"name": "TestChar2", "profession": "Ranger", "deaths": 5}, # No change + ] with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: mock_instance = mock_dal.return_value From 7e9d900ee689f0bf6befc652d0f84f5c9e9ac28b Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:42:45 -0300 Subject: [PATCH 08/13] v3.0.4 --- .env.example | 2 +- .github/workflows/workflow.yml | 4 +- pyproject.toml | 28 ++++----- src/bot/cogs/dice_rolls.py | 2 +- src/bot/cogs/events/on_message.py | 6 +- src/bot/cogs/events/on_user_update.py | 11 ++-- src/bot/cogs/open_ai.py | 2 +- src/bot/constants/settings.py | 2 +- src/bot/tools/bot_utils.py | 2 +- src/database/dal/gw2/gw2_key_dal.py | 2 +- src/gw2/cogs/account.py | 10 +-- src/gw2/cogs/characters.py | 8 ++- src/gw2/cogs/key.py | 4 +- src/gw2/cogs/misc.py | 2 +- tests/integration/test_gw2_key_dal.py | 2 + tests/unit/bot/cogs/test_dice_rolls.py | 28 +++++++++ tests/unit/bot/cogs/test_open_ai.py | 4 +- tests/unit/bot/constants/test_settings.py | 2 +- tests/unit/bot/events/test_member_events.py | 38 +++++++---- tests/unit/bot/events/test_on_message.py | 66 ++++++++++++++++++++ tests/unit/bot/tools/test_bot_utils.py | 32 ++++++++++ tests/unit/bot/tools/test_bot_utils_extra.py | 2 + tests/unit/database/test_dal.py | 5 +- tests/unit/gw2/cogs/test_account.py | 64 +++++++++++++++++++ tests/unit/gw2/cogs/test_characters.py | 46 ++++++++++++++ tests/unit/gw2/cogs/test_key.py | 60 ++++++++++++++++++ tests/unit/gw2/cogs/test_misc.py | 25 ++++++++ uv.lock | 26 ++------ 28 files changed, 406 insertions(+), 79 deletions(-) diff --git a/.env.example b/.env.example index 29353967..a665e84c 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ BOT_OPENAI_COOLDOWN=10 BOT_OWNER_COOLDOWN=5 # OpenAI API key -BOT_OPENAI_MODEL=gpt-4o-mini +BOT_OPENAI_MODEL=gpt-5.2 OPENAI_API_KEY= # ddcDatabases configs diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 72ac2262..bab7bcd7 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -41,7 +41,7 @@ jobs: with: timeout_minutes: 2 max_attempts: 3 - command: uv run --no-sync pytest tests/unit + command: uv run --no-sync coverage run -m pytest tests/unit && uv run --no-sync coverage report && uv run --no-sync coverage xml shell: bash - name: Upload coverage to Codecov @@ -75,7 +75,7 @@ jobs: with: timeout_minutes: 3 max_attempts: 3 - command: uv run --no-sync pytest tests/integration --no-cov + command: uv run --no-sync pytest tests/integration shell: bash docker: diff --git a/pyproject.toml b/pyproject.toml index 13f450e5..f7da531a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,37 +36,35 @@ dependencies = [ "ddcdatabases[postgres]>=3.0.10", "discord-py>=2.6.4", "gTTS>=2.5.4", - "openai>=2.21.0", + "openai>=2.22.0", "PyNaCl>=1.6.2", "pythonLogs>=6.0.2", ] [dependency-groups] dev = [ - "pytest-asyncio>=1.3.0", - "pytest-cov>=7.0.0", - "testcontainers[postgres]>=4.14.1", - "poethepoet>=0.42.0", - "ruff>=0.15.2", + "coverage>=7.13.4", + "poethepoet>=0.42.0", + "pytest-asyncio>=1.3.0", + "ruff>=0.15.2", + "testcontainers[postgres]>=4.14.1", ] [tool.poe.tasks] linter.shell = "uv run ruff check --fix . && uv run ruff format ." -profile = "uv run python -m cProfile -o cprofile_unit.prof -m pytest tests/unit --no-cov" -profile-integration = "uv run python -m cProfile -o cprofile_integration.prof -m pytest tests/integration --no-cov" -test = "uv run pytest tests/unit" -test-integration = "uv run pytest tests/integration --no-cov" +profile = "uv run python -m cProfile -o cprofile_unit.prof -m pytest tests/unit" +profile-integration = "uv run python -m cProfile -o cprofile_integration.prof -m pytest tests/integration" +test.sequence = [{ shell = "uv run coverage run -m pytest tests/unit" }, { shell = "uv run coverage report" }, { shell = "uv run coverage xml" }] +test-integration = "uv run pytest tests/integration" hadolint.shell = "docker run --rm -i -v $(pwd)/.hadolint.yml:/.config/hadolint.yml:ro hadolint/hadolint < Dockerfile" -test-docker = "uv run pytest tests/docker -v --no-cov" -migration = "uv run --frozen alembic upgrade head" +test-docker = "uv run coverage run -m pytest tests/docker" tests.sequence = ["linter", "hadolint", "test-docker", "test", "test-integration"] updatedev.sequence = ["linter", {shell = "uv lock --upgrade && uv sync --all-extras --group dev"}] +migration = "uv run --frozen alembic upgrade head" [tool.pytest.ini_options] -addopts = "-v --import-mode=importlib --cov --cov-report=term --cov-report=xml --junitxml=junit.xml" +addopts = "-v --junitxml=junit.xml" junit_family = "legacy" -pythonpath = ["."] -testpaths = ["tests/unit"] asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" markers = [ diff --git a/src/bot/cogs/dice_rolls.py b/src/bot/cogs/dice_rolls.py index a1fba412..24830def 100644 --- a/src/bot/cogs/dice_rolls.py +++ b/src/bot/cogs/dice_rolls.py @@ -208,7 +208,7 @@ def _create_roll_embed(author: discord.Member, message_parts: list[str]) -> disc """Create embed for roll result.""" description = "\n".join(message_parts) embed = discord.Embed(description=description, color=discord.Color.red()) - embed.set_author(name=author.display_name, icon_url=author.avatar.url) + embed.set_author(name=author.display_name, icon_url=author.display_avatar.url) return embed @staticmethod diff --git a/src/bot/cogs/events/on_message.py b/src/bot/cogs/events/on_message.py index e6939ae0..9884b6b8 100644 --- a/src/bot/cogs/events/on_message.py +++ b/src/bot/cogs/events/on_message.py @@ -77,7 +77,7 @@ async def _censor_message(self, ctx: commands.Context, user_msg: str) -> None: # Send censorship notification embed = discord.Embed(title="", color=discord.Color.red(), description=messages.MESSAGE_CENSURED) - embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.avatar.url) + embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.display_avatar.url) try: await ctx.message.channel.send(embed=embed) @@ -161,7 +161,7 @@ async def _send_reaction_message(message: discord.Message, response: str) -> Non await message.channel.typing() description = f"{messages.BOT_REACT_EMOJIS}\n{chat_formatting.inline(response)}" embed = discord.Embed(color=discord.Color.red(), description=description) - embed.set_author(name=message.author.display_name, icon_url=message.author.avatar.url) + embed.set_author(name=message.author.display_name, icon_url=message.author.display_avatar.url) await message.channel.send(embed=embed) @@ -311,7 +311,7 @@ async def _handle_invisible_member(self, ctx: commands.Context) -> None: color=discord.Color.red(), description=chat_formatting.error_inline(message_text), ) - embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar.url) + embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url) # Try to send DM, fall back to channel, then mention try: diff --git a/src/bot/cogs/events/on_user_update.py b/src/bot/cogs/events/on_user_update.py index ee80dd17..f74febca 100644 --- a/src/bot/cogs/events/on_user_update.py +++ b/src/bot/cogs/events/on_user_update.py @@ -26,16 +26,17 @@ async def on_user_update(self, before, after): msg = f"{messages.PROFILE_CHANGES}:\n\n" embed = bot_utils.get_embed(self) - embed.set_author(name=after.display_name, icon_url=after.avatar.url) + embed.set_author(name=after.display_name, icon_url=after.display_avatar.url) embed.set_footer( - icon_url=self.bot.user.avatar.url, + icon_url=self.bot.user.display_avatar.url, text=f"{bot_utils.get_current_date_time_str_long()} UTC", ) - if str(before.avatar.url) != str(after.avatar.url): - embed.set_thumbnail(url=after.avatar.url) + if before.avatar != after.avatar: + if after.avatar: + embed.set_thumbnail(url=after.avatar.url) embed.add_field(name=messages.NEW_AVATAR, value="-->") - msg += f"{messages.NEW_AVATAR}: \n{after.avatar.url}\n" + msg += f"{messages.NEW_AVATAR}: \n{after.display_avatar.url}\n" if str(before.name) != str(after.name): if before.name is not None: diff --git a/src/bot/cogs/open_ai.py b/src/bot/cogs/open_ai.py index 4f6bb78d..931e2b23 100644 --- a/src/bot/cogs/open_ai.py +++ b/src/bot/cogs/open_ai.py @@ -64,7 +64,7 @@ async def _get_ai_response(self, message: str) -> str: response = self.openai_client.chat.completions.create( model=model, messages=messages, - max_tokens=1000, + max_completion_tokens=1000, temperature=0.7, ) diff --git a/src/bot/constants/settings.py b/src/bot/constants/settings.py index 1a9e7def..eefede7f 100644 --- a/src/bot/constants/settings.py +++ b/src/bot/constants/settings.py @@ -23,7 +23,7 @@ class BotSettings(BaseSettings): exclusive_users: str | None = Field(default="") # OpenAi - openai_model: str | None = Field(default="gpt-4o-mini") + openai_model: str | None = Field(default="gpt-5.2") openai_api_key: str | None = Field(default=None) # Cooldowns diff --git a/src/bot/tools/bot_utils.py b/src/bot/tools/bot_utils.py index 7566066e..34f2f5e0 100644 --- a/src/bot/tools/bot_utils.py +++ b/src/bot/tools/bot_utils.py @@ -120,7 +120,7 @@ async def send_embed(ctx, embed, dm=False): if not embed.color: embed.color = ctx.bot.settings["bot"]["EmbedColor"] if not embed.author: - embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.avatar.url) + embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.display_avatar.url) if is_private_message(ctx): # Already in DM, just send the embed diff --git a/src/database/dal/gw2/gw2_key_dal.py b/src/database/dal/gw2/gw2_key_dal.py index 8326ee0f..ed351cfb 100644 --- a/src/database/dal/gw2/gw2_key_dal.py +++ b/src/database/dal/gw2/gw2_key_dal.py @@ -50,7 +50,7 @@ async def get_api_key(self, api_key: str): async def get_api_key_by_name(self, key_name: str): stmt = select(*self.columns).where(Gw2Keys.name == key_name) - results = await self.db_utils.fetchall(stmt) + results = await self.db_utils.fetchall(stmt, True) return results async def get_api_key_by_user(self, user_id: int): diff --git a/src/gw2/cogs/account.py b/src/gw2/cogs/account.py index ac3f97f7..5a73a0c5 100644 --- a/src/gw2/cogs/account.py +++ b/src/gw2/cogs/account.py @@ -94,7 +94,7 @@ async def account(ctx): description="🔄 **Please wait, I'm fetching your account data from GW2 API...** (this may take a moment)", color=color, ) - progress_embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.avatar.url) + progress_embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.display_avatar.url) progress_msg = await ctx.send(embed=progress_embed) # Start background typing keeper @@ -130,8 +130,8 @@ async def account(ctx): # Create base embed color = ctx.bot.settings["gw2"]["EmbedColor"] embed = discord.Embed(title="Account Name", description=chat_formatting.inline(acc_name), color=color) - embed.set_thumbnail(url=ctx.message.author.avatar.url) - embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.avatar.url) + embed.set_thumbnail(url=ctx.message.author.display_avatar.url) + embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.display_avatar.url) embed.add_field(name="Access", value=chat_formatting.inline(access), inline=False) embed.add_field(name="Commander Tag", value=chat_formatting.inline(is_commander)) embed.add_field(name="Server", value=chat_formatting.inline(f"{server_name} ({population})")) @@ -247,7 +247,9 @@ async def limited_guild_fetch(task): inline=False, ) - embed.set_footer(icon_url=ctx.bot.user.avatar.url, text=f"{bot_utils.get_current_date_time_str_long()} UTC") + embed.set_footer( + icon_url=ctx.bot.user.display_avatar.url, text=f"{bot_utils.get_current_date_time_str_long()} UTC" + ) # Stop the background typing task stop_typing.set() diff --git a/src/gw2/cogs/characters.py b/src/gw2/cogs/characters.py index 61bd8dcb..4eb04e40 100644 --- a/src/gw2/cogs/characters.py +++ b/src/gw2/cogs/characters.py @@ -59,8 +59,8 @@ async def characters(ctx): description=chat_formatting.inline(api_req_acc["name"]), color=color, ) - embed.set_thumbnail(url=ctx.message.author.avatar.url) - embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.avatar.url) + embed.set_thumbnail(url=ctx.message.author.display_avatar.url) + embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.display_avatar.url) api_req_characters = await gw2_api.call_api("characters", api_key) for char_name in api_req_characters: @@ -81,7 +81,9 @@ async def characters(ctx): ), ) - embed.set_footer(icon_url=ctx.bot.user.avatar.url, text=f"{bot_utils.get_current_date_time_str_long()} UTC") + embed.set_footer( + icon_url=ctx.bot.user.display_avatar.url, text=f"{bot_utils.get_current_date_time_str_long()} UTC" + ) await bot_utils.send_embed(ctx, embed) except Exception as e: diff --git a/src/gw2/cogs/key.py b/src/gw2/cogs/key.py index 7f6139c5..e29b4bae 100644 --- a/src/gw2/cogs/key.py +++ b/src/gw2/cogs/key.py @@ -338,7 +338,7 @@ async def info(ctx): """ user_id = ctx.message.author.id - author_icon_url = ctx.message.author.avatar.url + author_icon_url = ctx.message.author.display_avatar.url color = ctx.bot.settings["gw2"]["EmbedColor"] gw2_key_dal = Gw2KeyDal(ctx.bot.db_session, ctx.bot.log) gw2_api = Gw2Client(ctx.bot) @@ -386,7 +386,7 @@ async def info(ctx): inline=False, ) embed.add_field(name="Key", value=chat_formatting.inline(rs[0]["key"]), inline=False) - embed.set_footer(icon_url=ctx.bot.user.avatar.url, text=f"{bot_utils.get_current_date_time_str_long()} UTC") + embed.set_footer(icon_url=ctx.bot.user.display_avatar.url, text=f"{bot_utils.get_current_date_time_str_long()} UTC") await bot_utils.send_embed(ctx, embed, dm=True) diff --git a/src/gw2/cogs/misc.py b/src/gw2/cogs/misc.py index 05003503..212d5483 100644 --- a/src/gw2/cogs/misc.py +++ b/src/gw2/cogs/misc.py @@ -166,7 +166,7 @@ async def info(ctx, *, skill): embed = discord.Embed(title=skill_name, description=skill_description, color=color, url=skill_url) embed.set_thumbnail(url=skill_icon_url) - embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.avatar.url) + embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.display_avatar.url) await bot_utils.send_embed(ctx, embed) return None diff --git a/tests/integration/test_gw2_key_dal.py b/tests/integration/test_gw2_key_dal.py index e059422c..7e57e895 100644 --- a/tests/integration/test_gw2_key_dal.py +++ b/tests/integration/test_gw2_key_dal.py @@ -40,6 +40,8 @@ async def test_get_api_key_by_name(db_session, log): await dal.insert_api_key(_make_key_args()) results = await dal.get_api_key_by_name("Main Key") assert len(results) == 1 + assert isinstance(results[0], dict) + assert results[0]["name"] == "Main Key" async def test_update_api_key(db_session, log): diff --git a/tests/unit/bot/cogs/test_dice_rolls.py b/tests/unit/bot/cogs/test_dice_rolls.py index 7a129b38..6283b058 100644 --- a/tests/unit/bot/cogs/test_dice_rolls.py +++ b/tests/unit/bot/cogs/test_dice_rolls.py @@ -39,6 +39,8 @@ def mock_ctx(): author.display_name = "TestUser" author.avatar = MagicMock() author.avatar.url = "https://example.com/avatar.png" + author.display_avatar = MagicMock() + author.display_avatar.url = "https://example.com/avatar.png" ctx.author = author # Direct assignment ctx.message = MagicMock() @@ -497,3 +499,29 @@ async def test_roll_embed_properties(self, mock_send_embed, mock_dal_class, mock assert embed.author.name == "TestUser" assert embed.author.icon_url == "https://example.com/avatar.png" assert ":game_die: 42 :game_die:" in embed.description + + @pytest.mark.asyncio + @patch("src.bot.cogs.dice_rolls.random.SystemRandom") + @patch("src.bot.cogs.dice_rolls.DiceRollsDal") + @patch("src.bot.cogs.dice_rolls.bot_utils.send_embed") + async def test_roll_author_no_avatar(self, mock_send_embed, mock_dal_class, mock_random, dice_cog, mock_ctx): + """Test roll command does not crash when author has no custom avatar.""" + mock_ctx.author.avatar = None + mock_ctx.author.display_avatar = MagicMock() + mock_ctx.author.display_avatar.url = "https://example.com/default.png" + mock_ctx.message.author = mock_ctx.author + + mock_random_instance = MagicMock() + mock_random.return_value = mock_random_instance + mock_random_instance.randint.return_value = 42 + + mock_dal = AsyncMock() + mock_dal_class.return_value = mock_dal + mock_dal.get_user_roll_by_dice_size.return_value = None + mock_dal.insert_user_roll.return_value = None + mock_dal.get_server_max_roll.return_value = [] + + await dice_cog.roll.callback(dice_cog, mock_ctx) + + embed = mock_send_embed.call_args[0][1] + assert embed.author.icon_url == "https://example.com/default.png" diff --git a/tests/unit/bot/cogs/test_open_ai.py b/tests/unit/bot/cogs/test_open_ai.py index 02ef5221..e3722e23 100644 --- a/tests/unit/bot/cogs/test_open_ai.py +++ b/tests/unit/bot/cogs/test_open_ai.py @@ -168,7 +168,7 @@ async def test_get_ai_response_success( call_args = mock_client.chat.completions.create.call_args assert call_args[1]["model"] == "gpt-3.5-turbo" - assert call_args[1]["max_tokens"] == 1000 + assert call_args[1]["max_completion_tokens"] == 1000 assert call_args[1]["temperature"] == pytest.approx(0.7) # Verify message types and content @@ -345,7 +345,7 @@ async def test_get_ai_response_api_parameters( await openai_cog._get_ai_response("Test message") call_args = mock_client.chat.completions.create.call_args[1] - assert call_args["max_tokens"] == 1000 + assert call_args["max_completion_tokens"] == 1000 assert call_args["temperature"] == pytest.approx(0.7) assert call_args["model"] == "gpt-3.5-turbo" diff --git a/tests/unit/bot/constants/test_settings.py b/tests/unit/bot/constants/test_settings.py index 18dd15c6..bcdbf8ff 100644 --- a/tests/unit/bot/constants/test_settings.py +++ b/tests/unit/bot/constants/test_settings.py @@ -96,7 +96,7 @@ def test_partial_env_var_overrides(self): assert settings.admin_cooldown == 35 # Default values for non-overridden fields - assert settings.openai_model == "gpt-4o-mini" + assert settings.openai_model == "gpt-5.2" # Note: openai_api_key might have a value from actual env, so we'll check it's set assert settings.embed_color == "green" assert settings.config_cooldown == 20 diff --git a/tests/unit/bot/events/test_member_events.py b/tests/unit/bot/events/test_member_events.py index 091b06cc..77617db2 100644 --- a/tests/unit/bot/events/test_member_events.py +++ b/tests/unit/bot/events/test_member_events.py @@ -576,6 +576,8 @@ async def test_on_user_update_avatar_change( after.display_name = "TestUser" after.avatar = MagicMock() after.avatar.url = "https://example.com/new_avatar.png" + after.display_avatar = MagicMock() + after.display_avatar.url = "https://example.com/new_avatar.png" after.name = "TestUser" after.discriminator = "1234" @@ -615,17 +617,21 @@ async def test_on_user_update_name_change( mock_get_embed.return_value = embed # Setup before/after with different names + # Use the same avatar object so avatar comparison shows no change + shared_avatar = MagicMock() + shared_avatar.url = "https://example.com/avatar.png" + before = MagicMock() - before.avatar = MagicMock() - before.avatar.url = "https://example.com/avatar.png" + before.avatar = shared_avatar before.name = "OldName" before.discriminator = "1234" after = MagicMock() after.bot = False after.display_name = "NewName" - after.avatar = MagicMock() - after.avatar.url = "https://example.com/avatar.png" + after.avatar = shared_avatar + after.display_avatar = MagicMock() + after.display_avatar.url = "https://example.com/avatar.png" after.name = "NewName" after.discriminator = "1234" @@ -665,17 +671,21 @@ async def test_on_user_update_discriminator_change( mock_get_embed.return_value = embed # Setup before/after with different discriminators + # Use the same avatar object so avatar comparison shows no change + shared_avatar = MagicMock() + shared_avatar.url = "https://example.com/avatar.png" + before = MagicMock() - before.avatar = MagicMock() - before.avatar.url = "https://example.com/avatar.png" + before.avatar = shared_avatar before.name = "TestUser" before.discriminator = "1234" after = MagicMock() after.bot = False after.display_name = "TestUser" - after.avatar = MagicMock() - after.avatar.url = "https://example.com/avatar.png" + after.avatar = shared_avatar + after.display_avatar = MagicMock() + after.display_avatar.url = "https://example.com/avatar.png" after.name = "TestUser" after.discriminator = "5678" @@ -715,17 +725,21 @@ async def test_on_user_update_no_changes( mock_get_embed.return_value = embed # Setup before/after with identical values + # Use the same avatar object so before.avatar == after.avatar is True + shared_avatar = MagicMock() + shared_avatar.url = "https://example.com/avatar.png" + before = MagicMock() - before.avatar = MagicMock() - before.avatar.url = "https://example.com/avatar.png" + before.avatar = shared_avatar before.name = "TestUser" before.discriminator = "1234" after = MagicMock() after.bot = False after.display_name = "TestUser" - after.avatar = MagicMock() - after.avatar.url = "https://example.com/avatar.png" + after.avatar = shared_avatar + after.display_avatar = MagicMock() + after.display_avatar.url = "https://example.com/avatar.png" after.name = "TestUser" after.discriminator = "1234" diff --git a/tests/unit/bot/events/test_on_message.py b/tests/unit/bot/events/test_on_message.py index 20e163f1..3e339387 100644 --- a/tests/unit/bot/events/test_on_message.py +++ b/tests/unit/bot/events/test_on_message.py @@ -588,3 +588,69 @@ async def test_dm_handler_send_owner_help(self, mock_bot, mock_ctx): # both calls go to the same send method, so call_count should be 2 assert mock_ctx.message.author.send.call_count == 2 assert mock_ctx.author.send.call_count == 2 + + +class TestNoAvatarHandling: + """Test that message handlers work when users have no custom avatar.""" + + @pytest.mark.asyncio + @patch("src.bot.cogs.events.on_message.bot_utils.delete_message") + async def test_profanity_censor_author_no_avatar(self, mock_delete, mock_bot, mock_ctx): + """Test profanity censor embed does not crash when author has no avatar.""" + mock_ctx.message.author.avatar = None + mock_ctx.message.author.display_avatar = MagicMock() + mock_ctx.message.author.display_avatar.url = "https://example.com/default.png" + mock_ctx.author = mock_ctx.message.author + + mock_bot.profanity.contains_profanity.return_value = True + mock_bot.profanity.censor.return_value = "#### world" + filter_obj = ProfanityFilter(mock_bot) + + result = await filter_obj.check_and_censor(mock_ctx) + + assert result is True + mock_ctx.message.channel.send.assert_called() + + @pytest.mark.asyncio + async def test_reaction_handler_author_no_avatar(self, mock_bot): + """Test custom reaction handler does not crash when author has no avatar.""" + mock_bot.settings["bot"]["BotReactionWords"] = ["bad"] + handler = CustomReactionHandler(mock_bot) + + message = MagicMock() + message.system_content = "bad bot" + message.author = MagicMock() + message.author.display_name = "TestUser" + message.author.avatar = None + message.author.display_avatar = MagicMock() + message.author.display_avatar.url = "https://example.com/default.png" + message.channel = AsyncMock() + + result = await handler.check_and_react(message) + + assert result is True + message.channel.send.assert_called_once() + + @pytest.mark.asyncio + @patch("src.bot.cogs.events.on_message.ServersDal") + @patch("src.bot.cogs.events.on_message.bot_utils.delete_message") + async def test_invisible_member_no_avatar(self, mock_delete, mock_dal_class, mock_bot, mock_ctx): + """Test invisible member handling does not crash when author has no avatar.""" + mock_ctx.author.avatar = None + mock_ctx.author.display_avatar = MagicMock() + mock_ctx.author.display_avatar.url = "https://example.com/default.png" + mock_ctx.message.author = mock_ctx.author + mock_ctx.author.status = discord.Status.offline + + mock_dal = AsyncMock() + mock_dal_class.return_value = mock_dal + mock_dal.get_server.return_value = { + "block_invis_members": True, + "profanity_filter": False, + "bot_word_reactions": False, + } + + handler = ServerMessageHandler(mock_bot) + await handler.process(mock_ctx, False) + + mock_delete.assert_called_once_with(mock_ctx) diff --git a/tests/unit/bot/tools/test_bot_utils.py b/tests/unit/bot/tools/test_bot_utils.py index c9721fd3..4f3eabe5 100644 --- a/tests/unit/bot/tools/test_bot_utils.py +++ b/tests/unit/bot/tools/test_bot_utils.py @@ -357,6 +357,38 @@ async def test_send_error_msg(self, mock_send_embed, mock_ctx): assert embed_arg.color == discord.Color.red() +class TestSendEmbedNoAvatar: + """Test send_embed when author has no custom avatar.""" + + @pytest.fixture + def mock_ctx(self): + """Create a mock context with avatar=None.""" + ctx = MagicMock() + ctx.bot = MagicMock() + ctx.bot.settings = {"bot": {"EmbedColor": discord.Color.blue()}} + ctx.bot.logger = MagicMock() + ctx.message = MagicMock() + ctx.message.author = MagicMock() + ctx.message.author.display_name = "TestUser" + ctx.message.author.avatar = None + ctx.message.author.display_avatar = MagicMock() + ctx.message.author.display_avatar.url = "https://example.com/default.png" + ctx.channel = MagicMock(spec=discord.TextChannel) + ctx.send = AsyncMock() + ctx.author = MagicMock() + ctx.author.send = AsyncMock() + return ctx + + @pytest.mark.asyncio + async def test_send_embed_author_no_avatar(self, mock_ctx): + """Test send_embed does not crash when author has no custom avatar.""" + embed = discord.Embed(description="Test") + await bot_utils.send_embed(mock_ctx, embed) + mock_ctx.send.assert_called_once() + sent_embed = mock_ctx.send.call_args[1]["embed"] + assert sent_embed.author.icon_url == "https://example.com/default.png" + + class TestPermissionChecks: """Test permission checking functions.""" diff --git a/tests/unit/bot/tools/test_bot_utils_extra.py b/tests/unit/bot/tools/test_bot_utils_extra.py index 1deaa7c3..d6c05767 100644 --- a/tests/unit/bot/tools/test_bot_utils_extra.py +++ b/tests/unit/bot/tools/test_bot_utils_extra.py @@ -77,6 +77,8 @@ def mock_ctx(self): ctx.message.author.display_name = "TestUser" ctx.message.author.avatar = MagicMock() ctx.message.author.avatar.url = "https://example.com/avatar.png" + ctx.message.author.display_avatar = MagicMock() + ctx.message.author.display_avatar.url = "https://example.com/avatar.png" ctx.author = MagicMock() ctx.author.send = AsyncMock() ctx.author.display_name = "TestUser" diff --git a/tests/unit/database/test_dal.py b/tests/unit/database/test_dal.py index 2134e883..a3aee241 100644 --- a/tests/unit/database/test_dal.py +++ b/tests/unit/database/test_dal.py @@ -866,14 +866,13 @@ async def test_get_api_key_not_found(self, mock_dal): @pytest.mark.asyncio async def test_get_api_key_by_name(self, mock_dal): - """Test get_api_key_by_name calls fetchall without True.""" + """Test get_api_key_by_name calls fetchall with True for dict conversion.""" expected = [{"user_id": 67890, "name": "My Key"}] mock_dal.db_utils.fetchall.return_value = expected results = await mock_dal.get_api_key_by_name(key_name="My Key") mock_dal.db_utils.fetchall.assert_called_once() - # get_api_key_by_name does NOT pass True as second argument call_args = mock_dal.db_utils.fetchall.call_args - assert len(call_args[0]) == 1 + assert call_args[0][1] is True assert results == expected @pytest.mark.asyncio diff --git a/tests/unit/gw2/cogs/test_account.py b/tests/unit/gw2/cogs/test_account.py index 1eed5141..9d2ae656 100644 --- a/tests/unit/gw2/cogs/test_account.py +++ b/tests/unit/gw2/cogs/test_account.py @@ -52,6 +52,8 @@ def mock_ctx(self): ctx.bot.user = MagicMock() ctx.bot.user.avatar = MagicMock() ctx.bot.user.avatar.url = "https://example.com/bot_avatar.png" + ctx.bot.user.display_avatar = MagicMock() + ctx.bot.user.display_avatar.url = "https://example.com/bot_avatar.png" ctx.message = MagicMock() ctx.message.author = MagicMock() @@ -59,6 +61,8 @@ def mock_ctx(self): ctx.message.author.display_name = "TestUser" ctx.message.author.avatar = MagicMock() ctx.message.author.avatar.url = "https://example.com/user_avatar.png" + ctx.message.author.display_avatar = MagicMock() + ctx.message.author.display_avatar.url = "https://example.com/user_avatar.png" ctx.message.channel = MagicMock() ctx.message.channel.typing = AsyncMock() @@ -1074,3 +1078,63 @@ async def test_account_command_access_normalization(self, mock_ctx, sample_world assert "Heart Of Thorns" in access_field.value assert "Path Of Fire" in access_field.value assert "End Of Dragons" in access_field.value + + @pytest.mark.asyncio + async def test_account_author_no_avatar(self, mock_ctx, sample_world_data): + """Test account command does not crash when author has no custom avatar.""" + mock_ctx.message.author.avatar = None + mock_ctx.message.author.display_avatar = MagicMock() + mock_ctx.message.author.display_avatar.url = "https://example.com/default.png" + mock_ctx.bot.user.avatar = None + mock_ctx.bot.user.display_avatar = MagicMock() + mock_ctx.bot.user.display_avatar.url = "https://example.com/default_bot.png" + + api_key_data = [{"key": "test-api-key-12345", "permissions": "account"}] + account_data = { + "id": "account-id-123", + "name": "TestUser.1234", + "world": 1001, + "access": ["GuildWars2"], + "commander": False, + "fractal_level": 50, + "daily_ap": 3000, + "monthly_ap": 200, + "wvw_rank": 100, + "age": 525600, + "created": "2021-06-15T00:00:00.000Z", + } + + progress_msg = AsyncMock() + mock_ctx.send.return_value = progress_msg + + with ( + patch("src.gw2.cogs.account.Gw2KeyDal") as mock_dal, + patch("src.gw2.cogs.account.Gw2Client") as mock_client, + patch("src.gw2.cogs.account.bot_utils.send_embed") as mock_send_embed, + patch("src.gw2.cogs.account.bot_utils.get_current_date_time_str_long", return_value="2024-01-01 12:00:00"), + patch("src.gw2.cogs.account._keep_typing_alive", new=MagicMock()), + patch("src.gw2.cogs.account.asyncio.create_task") as mock_create_task, + patch("src.gw2.cogs.account.asyncio.Event") as mock_event_cls, + ): + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=api_key_data) + + mock_client_instance = mock_client.return_value + mock_client_instance.check_api_key = AsyncMock(return_value={"valid": True}) + mock_client_instance.call_api = AsyncMock( + side_effect=[ + account_data, + sample_world_data, + ] + ) + + mock_stop_event = MagicMock() + mock_event_cls.return_value = mock_stop_event + mock_task = MagicMock() + mock_create_task.return_value = mock_task + + await account(mock_ctx) + + mock_send_embed.assert_called_once() + embed = mock_send_embed.call_args[0][1] + assert embed.thumbnail.url == "https://example.com/default.png" + assert embed.author.icon_url == "https://example.com/default.png" diff --git a/tests/unit/gw2/cogs/test_characters.py b/tests/unit/gw2/cogs/test_characters.py index f700f953..5dcd6240 100644 --- a/tests/unit/gw2/cogs/test_characters.py +++ b/tests/unit/gw2/cogs/test_characters.py @@ -49,12 +49,16 @@ def mock_ctx(self): ctx.bot.user = MagicMock() ctx.bot.user.avatar = MagicMock() ctx.bot.user.avatar.url = "https://example.com/bot_avatar.png" + ctx.bot.user.display_avatar = MagicMock() + ctx.bot.user.display_avatar.url = "https://example.com/bot_avatar.png" ctx.message = MagicMock() ctx.message.author = MagicMock() ctx.message.author.id = 12345 ctx.message.author.display_name = "TestUser" ctx.message.author.avatar = MagicMock() ctx.message.author.avatar.url = "https://example.com/avatar.png" + ctx.message.author.display_avatar = MagicMock() + ctx.message.author.display_avatar.url = "https://example.com/avatar.png" ctx.message.channel = MagicMock() ctx.message.channel.typing = AsyncMock() ctx.prefix = "!" @@ -353,6 +357,48 @@ async def test_characters_embed_has_thumbnail_and_author(self, mock_ctx, sample_ assert embed.author.name == "TestUser" assert embed.author.icon_url == "https://example.com/avatar.png" + @pytest.mark.asyncio + async def test_characters_author_no_avatar(self, mock_ctx, sample_api_key_data, sample_account_data): + """Test characters command does not crash when author has no custom avatar.""" + mock_ctx.message.author.avatar = None + mock_ctx.message.author.display_avatar = MagicMock() + mock_ctx.message.author.display_avatar.url = "https://example.com/default.png" + mock_ctx.bot.user.avatar = None + mock_ctx.bot.user.display_avatar = MagicMock() + mock_ctx.bot.user.display_avatar.url = "https://example.com/default_bot.png" + + with patch("src.gw2.cogs.characters.Gw2KeyDal") as mock_dal: + mock_instance = mock_dal.return_value + mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) + with patch("src.gw2.cogs.characters.Gw2Client") as mock_client: + mock_client_instance = mock_client.return_value + mock_client_instance.check_api_key = AsyncMock( + return_value={"name": "TestKey", "permissions": ["account", "characters"]} + ) + mock_client_instance.call_api = AsyncMock( + side_effect=[ + sample_account_data, + ["SingleChar"], + { + "race": "Charr", + "gender": "Male", + "profession": "Engineer", + "level": 80, + "deaths": 0, + "age": 1440, + "created": "2024-01-01T00:00:00Z", + }, + ] + ) + with patch("src.gw2.cogs.characters.bot_utils.send_embed") as mock_send: + with patch("src.gw2.cogs.characters.bot_utils.get_current_date_time_str_long") as mock_time: + mock_time.return_value = "2025-01-01 12:00:00" + await characters(mock_ctx) + mock_send.assert_called_once() + embed = mock_send.call_args[0][1] + assert embed.thumbnail.url == "https://example.com/default.png" + assert embed.author.icon_url == "https://example.com/default.png" + class TestCharactersSetup: """Test cases for characters cog setup.""" diff --git a/tests/unit/gw2/cogs/test_key.py b/tests/unit/gw2/cogs/test_key.py index 793486d6..7b05ac36 100644 --- a/tests/unit/gw2/cogs/test_key.py +++ b/tests/unit/gw2/cogs/test_key.py @@ -603,11 +603,15 @@ def mock_ctx(self): ctx.bot.user = MagicMock() ctx.bot.user.avatar = MagicMock() ctx.bot.user.avatar.url = "https://example.com/bot_avatar.png" + ctx.bot.user.display_avatar = MagicMock() + ctx.bot.user.display_avatar.url = "https://example.com/bot_avatar.png" ctx.message = MagicMock() ctx.message.author = MagicMock() ctx.message.author.id = 12345 ctx.message.author.avatar = MagicMock() ctx.message.author.avatar.url = "https://example.com/avatar.png" + ctx.message.author.display_avatar = MagicMock() + ctx.message.author.display_avatar.url = "https://example.com/avatar.png" ctx.message.author.__str__ = MagicMock(return_value="TestUser#1234") ctx.prefix = "!" ctx.send = AsyncMock() @@ -719,6 +723,62 @@ async def test_info_exception_during_check(self, mock_ctx): mock_ctx.bot.log.error.assert_called_once() +class TestKeyInfoNoAvatar: + """Test that key info command works when user has no custom avatar.""" + + @pytest.fixture + def mock_ctx(self): + """Create a mock command context with avatar=None.""" + ctx = MagicMock() + ctx.bot = MagicMock() + ctx.bot.db_session = MagicMock() + ctx.bot.log = MagicMock() + ctx.bot.settings = {"gw2": {"EmbedColor": 0x00FF00}} + ctx.bot.user = MagicMock() + ctx.bot.user.avatar = None + ctx.bot.user.display_avatar = MagicMock() + ctx.bot.user.display_avatar.url = "https://example.com/default_bot.png" + ctx.message = MagicMock() + ctx.message.author = MagicMock() + ctx.message.author.id = 12345 + ctx.message.author.avatar = None + ctx.message.author.display_avatar = MagicMock() + ctx.message.author.display_avatar.url = "https://example.com/default.png" + ctx.message.author.__str__ = MagicMock(return_value="TestUser#1234") + ctx.prefix = "!" + ctx.send = AsyncMock() + return ctx + + @pytest.mark.asyncio + async def test_key_info_author_no_avatar(self, mock_ctx): + """Test info command does not crash when author has no custom avatar.""" + with patch("src.gw2.cogs.key.Gw2KeyDal") as mock_dal: + mock_instance = mock_dal.return_value + mock_instance.get_api_key_by_user = AsyncMock( + return_value=[ + { + "key": "test-api-key-12345", + "name": "TestKey", + "gw2_acc_name": "TestUser.1234", + "server": "Anvil Rock", + "permissions": "account,characters,progression", + } + ] + ) + with patch("src.gw2.cogs.key.Gw2Client") as mock_client: + mock_client_instance = mock_client.return_value + mock_client_instance.check_api_key = AsyncMock( + return_value={"name": "TestKey", "permissions": ["account", "characters", "progression"]} + ) + with patch("src.gw2.cogs.key.bot_utils.send_embed") as mock_send: + with patch("src.gw2.cogs.key.bot_utils.get_current_date_time_str_long") as mock_time: + mock_time.return_value = "2025-01-01 12:00:00" + await info(mock_ctx) + mock_send.assert_called_once() + embed = mock_send.call_args[0][1] + assert embed.author.icon_url == "https://example.com/default.png" + + class TestKeySetup: """Test cases for key cog setup.""" diff --git a/tests/unit/gw2/cogs/test_misc.py b/tests/unit/gw2/cogs/test_misc.py index 0702f41e..f96ba321 100644 --- a/tests/unit/gw2/cogs/test_misc.py +++ b/tests/unit/gw2/cogs/test_misc.py @@ -361,6 +361,8 @@ def mock_ctx(self): ctx.message.author.display_name = "TestUser" ctx.message.author.avatar = MagicMock() ctx.message.author.avatar.url = "https://example.com/avatar.png" + ctx.message.author.display_avatar = MagicMock() + ctx.message.author.display_avatar.url = "https://example.com/avatar.png" ctx.message.channel = MagicMock() ctx.message.channel.typing = AsyncMock() ctx.prefix = "!" @@ -817,6 +819,29 @@ async def test_info_tp_url_format(self, mock_ctx): assert "gw2tp.com" in embed.description assert "gw2bltc.com" in embed.description + @pytest.mark.asyncio + async def test_gw2_info_author_no_avatar(self, mock_ctx): + """Test info command does not crash when author has no custom avatar.""" + mock_ctx.message.author.avatar = None + mock_ctx.message.author.display_avatar = MagicMock() + mock_ctx.message.author.display_avatar.url = "https://example.com/default.png" + + html = self._make_info_html( + skill_name="Eternity", + description="A legendary greatsword.", + ) + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.url = "https://wiki.guildwars2.com/wiki/Eternity" + mock_response.text = AsyncMock(return_value=html) + mock_ctx.bot.aiosession.get = MagicMock(return_value=AsyncContextManager(mock_response)) + + with patch("src.gw2.cogs.misc.bot_utils.send_embed", new_callable=AsyncMock) as mock_send: + await info(mock_ctx, skill="Eternity") + mock_send.assert_called_once() + embed = mock_send.call_args[0][1] + assert embed.author.icon_url == "https://example.com/default.png" + class TestMiscSetup: """Test cases for misc cog setup.""" diff --git a/uv.lock b/uv.lock index 5a27651f..273d9478 100644 --- a/uv.lock +++ b/uv.lock @@ -381,9 +381,9 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "coverage" }, { name = "poethepoet" }, { name = "pytest-asyncio" }, - { name = "pytest-cov" }, { name = "ruff" }, { name = "testcontainers" }, ] @@ -396,16 +396,16 @@ requires-dist = [ { name = "ddcdatabases", extras = ["postgres"], specifier = ">=3.0.10" }, { name = "discord-py", specifier = ">=2.6.4" }, { name = "gtts", specifier = ">=2.5.4" }, - { name = "openai", specifier = ">=2.21.0" }, + { name = "openai", specifier = ">=2.22.0" }, { name = "pynacl", specifier = ">=1.6.2" }, { name = "pythonlogs", specifier = ">=6.0.2" }, ] [package.metadata.requires-dev] dev = [ + { name = "coverage", specifier = ">=7.13.4" }, { name = "poethepoet", specifier = ">=0.42.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, - { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.15.2" }, { name = "testcontainers", extras = ["postgres"], specifier = ">=4.14.1" }, ] @@ -689,7 +689,7 @@ wheels = [ [[package]] name = "openai" -version = "2.21.0" +version = "2.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -701,9 +701,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/ed/0a004a42fea6b6f3dd4ab33235183e994a4c7ade214fba10d9494577ec04/openai-2.22.0.tar.gz", hash = "sha256:fc2ea71c79951ac3faf178ff72c766bb4b09c3e9aab277184c5260ab3e94294f", size = 657093, upload-time = "2026-02-23T20:14:31.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9a/ac24d606ea7e729475100689a1fe8866fe6cbcd0fd9b93dc4b8324be353d/openai-2.22.0-py3-none-any.whl", hash = "sha256:df02cfb731fe312215d046bf1330030e0f4b70a7b880b96992b1517b0b6aced8", size = 1118913, upload-time = "2026-02-23T20:14:29.546Z" }, ] [[package]] @@ -969,20 +969,6 @@ 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 = "python-dotenv" version = "1.2.1" From 2c06356d49d57bae5e4474a93a79a5a2bfe92921 Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:57:22 -0300 Subject: [PATCH 09/13] v3.0.4 --- pyproject.toml | 2 +- tests/integration/test_session_flow.py | 201 +++++++++++++++++++++++++ tests/unit/gw2/__init__.py | 0 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 tests/integration/test_session_flow.py delete mode 100644 tests/unit/gw2/__init__.py diff --git a/pyproject.toml b/pyproject.toml index f7da531a..acab2881 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ updatedev.sequence = ["linter", {shell = "uv lock --upgrade && uv sync --all-ext migration = "uv run --frozen alembic upgrade head" [tool.pytest.ini_options] -addopts = "-v --junitxml=junit.xml" +addopts = "-v --junitxml=junit.xml --import-mode=importlib" junit_family = "legacy" asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" diff --git a/tests/integration/test_session_flow.py b/tests/integration/test_session_flow.py new file mode 100644 index 00000000..caadb132 --- /dev/null +++ b/tests/integration/test_session_flow.py @@ -0,0 +1,201 @@ +"""Integration test for the full GW2 session start → end lifecycle. + +Exercises start_session() and end_session() against a real PostgreSQL database +with mocked GW2 API responses to verify the full flow works end-to-end. +""" + +import pytest +from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharsDal +from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal +from src.gw2.tools import gw2_utils +from unittest.mock import AsyncMock, MagicMock, patch + +pytestmark = [pytest.mark.integration, pytest.mark.asyncio] + +USER_ID = 700 +API_KEY = "SESSION-FLOW-KEY-1234" + +MOCK_ACCOUNT_DATA = { + "name": "FlowTest.9999", + "world": 1001, + "access": ["GuildWars2"], + "commander": False, + "fractal_level": 50, + "daily_ap": 3000, + "monthly_ap": 200, + "wvw_rank": 250, + "age": 525600, + "created": "2021-06-15T00:00:00.000Z", +} + +MOCK_WALLET_DATA = [ + {"id": 1, "value": 100000}, # gold + {"id": 2, "value": 50000}, # karma + {"id": 3, "value": 10}, # laurels + {"id": 15, "value": 200}, # badges_honor + {"id": 16, "value": 5}, # guild_commendations + {"id": 26, "value": 30}, # wvw_tickets + {"id": 31, "value": 15}, # proof_heroics + {"id": 36, "value": 3}, # test_heroics +] + +MOCK_ACHIEVEMENTS_DATA = [ + {"id": 283, "current": 100}, # players + {"id": 285, "current": 50}, # yaks_scorted + {"id": 288, "current": 30}, # yaks + {"id": 291, "current": 20}, # camps + {"id": 294, "current": 5}, # castles + {"id": 297, "current": 15}, # towers + {"id": 300, "current": 10}, # keeps +] + +MOCK_CHARACTERS = ["Warrior Prime", "Thief Shadow"] + +MOCK_CHAR_CORES = { + "Warrior Prime": {"name": "Warrior Prime", "profession": "Warrior", "deaths": 42}, + "Thief Shadow": {"name": "Thief Shadow", "profession": "Thief", "deaths": 10}, +} + +# End-of-session data: wallet gold increased, character deaths increased +MOCK_WALLET_DATA_END = [ + {"id": 1, "value": 120000}, # gold increased + {"id": 2, "value": 55000}, # karma increased + {"id": 3, "value": 10}, + {"id": 15, "value": 200}, + {"id": 16, "value": 5}, + {"id": 26, "value": 30}, + {"id": 31, "value": 15}, + {"id": 36, "value": 3}, +] + +MOCK_ACHIEVEMENTS_DATA_END = [ + {"id": 283, "current": 105}, # players increased + {"id": 285, "current": 50}, + {"id": 288, "current": 30}, + {"id": 291, "current": 20}, + {"id": 294, "current": 5}, + {"id": 297, "current": 15}, + {"id": 300, "current": 10}, +] + +MOCK_CHAR_CORES_END = { + "Warrior Prime": {"name": "Warrior Prime", "profession": "Warrior", "deaths": 45}, # 3 more deaths + "Thief Shadow": {"name": "Thief Shadow", "profession": "Thief", "deaths": 12}, # 2 more deaths +} + + +def _mock_call_api_start(endpoint, api_key): + """Mock call_api for start_session flow.""" + if endpoint == "account": + return MOCK_ACCOUNT_DATA + if endpoint == "account/wallet": + return MOCK_WALLET_DATA + if endpoint == "account/achievements": + return MOCK_ACHIEVEMENTS_DATA + if endpoint == "characters": + return MOCK_CHARACTERS + if endpoint.startswith("characters/") and endpoint.endswith("/core"): + char_name = endpoint.split("/")[1] + return MOCK_CHAR_CORES[char_name] + raise ValueError(f"Unexpected endpoint: {endpoint}") + + +def _mock_call_api_end(endpoint, api_key): + """Mock call_api for end_session flow.""" + if endpoint == "account": + return MOCK_ACCOUNT_DATA + if endpoint == "account/wallet": + return MOCK_WALLET_DATA_END + if endpoint == "account/achievements": + return MOCK_ACHIEVEMENTS_DATA_END + if endpoint == "characters": + return MOCK_CHARACTERS + if endpoint.startswith("characters/") and endpoint.endswith("/core"): + char_name = endpoint.split("/")[1] + return MOCK_CHAR_CORES_END[char_name] + raise ValueError(f"Unexpected endpoint: {endpoint}") + + +async def test_session_start_end_lifecycle(db_session, log): + """Full lifecycle: start_session → verify DB → end_session → verify DB.""" + mock_bot = MagicMock() + mock_bot.db_session = db_session + mock_bot.log = log + + mock_member = MagicMock() + mock_member.id = USER_ID + + # ---- START SESSION ---- + mock_gw2_api_start = AsyncMock() + mock_gw2_api_start.call_api = AsyncMock(side_effect=_mock_call_api_start) + + with patch("src.gw2.tools.gw2_utils.Gw2Client", return_value=mock_gw2_api_start): + await gw2_utils.start_session(mock_bot, mock_member, API_KEY) + + # Verify session record was inserted + sessions_dal = Gw2SessionsDal(db_session, log) + sessions = await sessions_dal.get_user_last_session(USER_ID) + assert len(sessions) == 1 + session = sessions[0] + assert session["acc_name"] == "FlowTest.9999" + assert session["start"]["gold"] == 100000 + assert session["start"]["karma"] == 50000 + assert session["start"]["wvw_rank"] == 250 + assert session["end"] is None # Not ended yet + + # Verify start characters were inserted + chars_dal = Gw2SessionCharsDal(db_session, log) + start_chars = await chars_dal.get_all_start_characters(USER_ID) + assert len(start_chars) == 2 + char_names = {c["name"] for c in start_chars} + assert char_names == {"Warrior Prime", "Thief Shadow"} + warrior = next(c for c in start_chars if c["name"] == "Warrior Prime") + assert warrior["profession"] == "Warrior" + assert warrior["deaths"] == 42 + + # ---- END SESSION ---- + mock_gw2_api_end = AsyncMock() + mock_gw2_api_end.call_api = AsyncMock(side_effect=_mock_call_api_end) + + with patch("src.gw2.tools.gw2_utils.Gw2Client", return_value=mock_gw2_api_end): + await gw2_utils.end_session(mock_bot, mock_member, API_KEY) + + # Verify session end JSONB was populated + sessions = await sessions_dal.get_user_last_session(USER_ID) + assert len(sessions) == 1 + session = sessions[0] + assert session["end"] is not None + assert session["end"]["gold"] == 120000 + assert session["end"]["karma"] == 55000 + + # NOTE: End character insertion fails due to UniqueConstraint("name") on + # gw2_session_chars — start chars already occupy those names. The code + # catches the IntegrityError silently (logged as error). This is a known + # schema limitation; start chars are still queryable. + end_chars = await chars_dal.get_all_end_characters(USER_ID) + assert len(end_chars) == 0 # blocked by unique constraint on name + + +async def test_end_session_without_start_is_noop(db_session, log): + """end_session with no prior start should not crash and should log a warning.""" + mock_log = MagicMock() + mock_bot = MagicMock() + mock_bot.db_session = db_session + mock_bot.log = mock_log + + mock_member = MagicMock() + mock_member.id = 999888 # No session exists for this user + + mock_gw2_api = AsyncMock() + mock_gw2_api.call_api = AsyncMock(side_effect=_mock_call_api_end) + + with patch("src.gw2.tools.gw2_utils.Gw2Client", return_value=mock_gw2_api): + await gw2_utils.end_session(mock_bot, mock_member, API_KEY) + + mock_log.warning.assert_called_once() + assert "999888" in mock_log.warning.call_args[0][0] + + # No session should exist + sessions_dal = Gw2SessionsDal(db_session, log) + sessions = await sessions_dal.get_user_last_session(999888) + assert len(sessions) == 0 diff --git a/tests/unit/gw2/__init__.py b/tests/unit/gw2/__init__.py deleted file mode 100644 index e69de29b..00000000 From 0000dc2fd0609ed2e903ad03b47bbeac099fe66f Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:23:27 -0300 Subject: [PATCH 10/13] v3.0.4 --- .github/PULL_REQUEST_TEMPLATE | 36 +- pyproject.toml | 3 +- src/bot/constants/messages.py | 593 +++-- .../0011_drop_unique_session_chars_name.py | 24 + src/database/models/gw2_models.py | 2 +- src/gw2/cogs/sessions.py | 197 +- src/gw2/constants/gw2_currencies.py | 184 ++ src/gw2/constants/gw2_messages.py | 20 +- src/gw2/tools/gw2_utils.py | 68 +- tests/integration/test_alembic_migrations.py | 12 +- tests/integration/test_gw2_api_public.py | 124 + tests/integration/test_session_flow.py | 12 +- tests/unit/bot/constants/test_messages.py | 604 +++++ tests/unit/gw2/cogs/test_sessions.py | 2257 +++++------------ .../unit/gw2/constants/test_gw2_currencies.py | 199 ++ tests/unit/gw2/tools/test_gw2_utils.py | 174 +- uv.lock | 34 +- 17 files changed, 2433 insertions(+), 2110 deletions(-) create mode 100644 src/database/migrations/versions/0011_drop_unique_session_chars_name.py create mode 100644 src/gw2/constants/gw2_currencies.py create mode 100644 tests/integration/test_gw2_api_public.py create mode 100644 tests/unit/bot/constants/test_messages.py create mode 100644 tests/unit/gw2/constants/test_gw2_currencies.py diff --git a/.github/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE index e1ae10c6..f6bccffa 100644 --- a/.github/PULL_REQUEST_TEMPLATE +++ b/.github/PULL_REQUEST_TEMPLATE @@ -1,16 +1,32 @@ ## Summary + - + +## Changes Made + +- + + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change (existing functionality affected) +- [ ] Refactoring (no functional changes) +- [ ] Documentation +- [ ] CI/CD or build configuration +- [ ] Dependencies update + +## Testing +- [ ] Unit tests added/updated +- [ ] Integration tests added/updated +- [ ] All existing tests pass +- [ ] Manual testing performed ## Checklist -- [ ] If code changes were made, then they have been tested -- [ ] I have updated the documentation to reflect any changes made -- [ ] I have thought about how this code may affect other services -- [ ] This PR fixes an issue -- [ ] This PR is a breaking change (e.g. method, parameters, env variables) -- [ ] This PR adds something new (e.g. method, parameters, env variables) -- [ ] This PR change unit and integration tests -- [ ] This PR is **NOT** a code change (e.g. documentation, packages) +- [ ] Code follows the project's style and conventions +- [ ] Documentation updated (if applicable) +- [ ] No new warnings or linter errors introduced +- [ ] I have considered how this change may affect other services ## Reviewer -- [ ] I understand that approving this code, I am also responsible for it going into the codebase +- [ ] I understand that by approving this PR, I share responsibility for these changes diff --git a/pyproject.toml b/pyproject.toml index acab2881..e12fdd04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "ddcdatabases[postgres]>=3.0.10", "discord-py>=2.6.4", "gTTS>=2.5.4", - "openai>=2.22.0", + "openai>=2.23.0", "PyNaCl>=1.6.2", "pythonLogs>=6.0.2", ] @@ -70,6 +70,7 @@ asyncio_default_fixture_loop_scope = "function" markers = [ "integration: marks tests as integration tests", "docker: Docker and compose file tests", + "gw2_api: marks tests that require GW2 API access", ] [tool.coverage.run] omit = [ diff --git a/src/bot/constants/messages.py b/src/bot/constants/messages.py index 835f8a92..745d47f6 100644 --- a/src/bot/constants/messages.py +++ b/src/bot/constants/messages.py @@ -1,234 +1,371 @@ -################################# -# BOT -################################# -BOT_TOKEN_NOT_FOUND = "BOT_TOKEN variable not found" -BOT_TERMINATED = "Bot has been terminated." -BOT_STOPPED_CTRTC = "Bot stopped with Ctrl+C" -BOT_FATAL_ERROR_MAIN = "Fatal error in main()" -BOT_CRASHED = "Bot crashed" -BOT_CLOSING = "Closing bot..." -BOT_LOGIN_FAILED = "Bot login failed" -BOT_INIT_PREFIX_FAILED = "Failed to get prefix from database, using default" -BOT_LOAD_SETTINGS_FAILED = "Failed to load settings" -BOT_LOAD_COGS_FAILED = "Failed to load cogs" -BOT_LOADED_ALL_COGS_SUCCESS = "Successfully loaded all cogs" - - -def bot_online(bot_user) -> str: - return f"====> {bot_user} IS ONLINE AND CONNECTED TO DISCORD <====" - - -def bot_starting(seconds: int) -> str: - return f"Starting Bot in {seconds} secs" - - -def bot_disconnected(bot_user) -> str: - return f"Bot {bot_user} disconnected from Discord" - - -################################# -# EVENT ADMIN -################################# - - -def bot_announce_playing(game: str) -> str: - return f"I'm now playing: {game}" - - -def bg_task_warning(seconds: int) -> str: - return f"Background task running to update bot activity is ON\nActivity will change after {seconds} secs." - - -################################# -# EVENT CONFIG -################################# -CONFIG_JOIN = "Display a message when someone joins the server" -CONFIG_LEAVE = "Display a message when a member leaves the server" -CONFIG_SERVER = "Display a message when server gets updated" -CONFIG_MEMBER = "Display a message when someone changes profile" -CONFIG_BLOCK_INVIS_MEMBERS = "Block messages from invisible members" -CONFIG_BOT_WORD_REACTIONS = "Bot word reactions" -CONFIG_PFILTER_CHANNELS = "Channels with profanity filter activated" - - -def config_pfilter(status: str, channel: str) -> str: - return f"Profanity Filter `{status}`\nChannel: `{channel}`" - - -CONFIG_CHANNEL_ID_INSTEAD_NAME = "Chnanel id should be used instead of its name!!!" -CONFIG_NOT_ACTIVATED_ERROR = "Profanity Filter could not be activated.\n" -MISING_REUIRED_ARGUMENT = "Missing required argument!!!" -CHANNEL_ID_NOT_FOUND = "Channel id not found" -BOT_MISSING_MANAGE_MESSAGES_PERMISSION = 'Bot does not have permission to "Manage Messages"' -NO_CHANNELS_LISTED = "No channels listed" -################################# -# EVENT CUSTOM COMMAND -################################# -ALREADY_A_STANDARD_COMMAND = "is already a standard command" -COMMAND_LENGHT_ERROR = "Command names cannot exceed 20 characters.\nPlease try again with another name." -CUSTOM_COMMAND_ADDED = "Custom command successfully added" -CUSTOM_COMMAND_EDITED = "Custom command successfully edited" -CUSTOM_COMMAND_REMOVED = "Custom command successfully removed" -CUSTOM_COMMAND_ALL_REMOVED = "All custom commands from this server were successfully removed." -COMMAND_ALREADY_EXISTS = "Command already exists" -NO_CUSTOM_COMMANDS_FOUND = "There are no custom commands in this server." -CUSTOM_COMMAND_UNABLE_REMOVE = "Unable to remove!!!" -CUSTOM_COMMANDS_SERVER = "Custom commands in this server" -GET_CONFIGS_ERROR = "Error getting server configs" -################################# -# EVENT ON COMMAND ERROR -################################# -MISSING_REQUIRED_ARGUMENT_HELP_MESSAGE = "Missing required argument!!!\nFor more info on this command" -COMMAND_NOT_FOUND = "Command not found" -COMMAND_ERROR = "Command ERROR" -COMMAND_RAISED_EXCEPTION = "Command raised an exception" -NOT_ADMIN_USE_COMMAND = "You are not an Admin to use this command" -BOT_OWNERS_ONLY_COMMAND = "Only bot owners can use this command" -PREFIXES_CHOICE = "Prefixes can only be one of" -MORE_INFO = "For more info" -UNKNOWN_OPTION = "Unknown option" -HELP_COMMAND_MORE_INFO = "For more info on this command" -NO_OPTION_FOUND = "No option found" -NO_PERMISSION_EXECUTE_COMMAND = "Bot does not have permission to execute this command" -INVALID_MESSAGE = "Invalid message." -COMMAND_INTERNAL_ERROR = "There was an internal error with command" -DM_CANNOT_EXECUTE_COMMAND = "Cannot execute action on a DM channel" -PRIVILEGE_LOW = "Your Privilege is too low." -DIRECT_MESSAGES_DISABLED = ( - "Direct messages are disable in your configuration.\n" - "If you want to receive messages from Bots, " - "you need to enable this option under Privacy & Safety:" - '"Allow direct messages from server members."' -) -################################# -# EVENT ON GUILD JOIN -################################# - - -def guild_join_bot_message(bot_name: str, prefix: str, games_included: str) -> str: - return ( - f"Thanks for using *{bot_name}*\n" - f"To learn more about this bot: `{prefix}about`\n" - f"Games included so far: `{games_included}`\n\n" - f"If you are an Admin and wish to list configurations: `{prefix}config list`\n" - f"To get a list of commands: `{prefix}help`" +"""Bot message constants organized by domain.""" + + +class Bot: + TOKEN_NOT_FOUND = "BOT_TOKEN variable not found" + TERMINATED = "Bot has been terminated." + STOPPED_CTRTC = "Bot stopped with Ctrl+C" + FATAL_ERROR_MAIN = "Fatal error in main()" + CRASHED = "Bot crashed" + CLOSING = "Closing bot..." + LOGIN_FAILED = "Bot login failed" + INIT_PREFIX_FAILED = "Failed to get prefix from database, using default" + LOAD_SETTINGS_FAILED = "Failed to load settings" + LOAD_COGS_FAILED = "Failed to load cogs" + LOADED_ALL_COGS_SUCCESS = "Successfully loaded all cogs" + + @staticmethod + def online(bot_user) -> str: + return f"====> {bot_user} IS ONLINE AND CONNECTED TO DISCORD <====" + + @staticmethod + def starting(seconds: int) -> str: + return f"Starting Bot in {seconds} secs" + + @staticmethod + def disconnected(bot_user) -> str: + return f"Bot {bot_user} disconnected from Discord" + + +class Admin: + @staticmethod + def announce_playing(game: str) -> str: + return f"I'm now playing: {game}" + + @staticmethod + def bg_task_warning(seconds: int) -> str: + return f"Background task running to update bot activity is ON\nActivity will change after {seconds} secs." + + +class Config: + JOIN = "Display a message when someone joins the server" + LEAVE = "Display a message when a member leaves the server" + SERVER = "Display a message when server gets updated" + MEMBER = "Display a message when someone changes profile" + BLOCK_INVIS_MEMBERS = "Block messages from invisible members" + BOT_WORD_REACTIONS = "Bot word reactions" + PFILTER_CHANNELS = "Channels with profanity filter activated" + CHANNEL_ID_INSTEAD_NAME = "Chnanel id should be used instead of its name!!!" + NOT_ACTIVATED_ERROR = "Profanity Filter could not be activated.\n" + MISSING_REQUIRED_ARGUMENT = "Missing required argument!!!" + CHANNEL_ID_NOT_FOUND = "Channel id not found" + BOT_MISSING_MANAGE_MESSAGES = 'Bot does not have permission to "Manage Messages"' + NO_CHANNELS_LISTED = "No channels listed" + + @staticmethod + def pfilter(status: str, channel: str) -> str: + return f"Profanity Filter `{status}`\nChannel: `{channel}`" + + +class CustomCommand: + ALREADY_A_STANDARD_COMMAND = "is already a standard command" + LENGTH_ERROR = "Command names cannot exceed 20 characters.\nPlease try again with another name." + ADDED = "Custom command successfully added" + EDITED = "Custom command successfully edited" + REMOVED = "Custom command successfully removed" + ALL_REMOVED = "All custom commands from this server were successfully removed." + ALREADY_EXISTS = "Command already exists" + NO_COMMANDS_FOUND = "There are no custom commands in this server." + UNABLE_REMOVE = "Unable to remove!!!" + COMMANDS_SERVER = "Custom commands in this server" + GET_CONFIGS_ERROR = "Error getting server configs" + + +class CommandError: + MISSING_REQUIRED_ARGUMENT_HELP = "Missing required argument!!!\nFor more info on this command" + NOT_FOUND = "Command not found" + ERROR = "Command ERROR" + RAISED_EXCEPTION = "Command raised an exception" + NOT_ADMIN = "You are not an Admin to use this command" + OWNERS_ONLY = "Only bot owners can use this command" + PREFIXES_CHOICE = "Prefixes can only be one of" + MORE_INFO = "For more info" + UNKNOWN_OPTION = "Unknown option" + HELP_MORE_INFO = "For more info on this command" + NO_OPTION_FOUND = "No option found" + NO_PERMISSION = "Bot does not have permission to execute this command" + INVALID_MESSAGE = "Invalid message." + INTERNAL_ERROR = "There was an internal error with command" + DM_CANNOT_EXECUTE = "Cannot execute action on a DM channel" + PRIVILEGE_LOW = "Your Privilege is too low." + DIRECT_MESSAGES_DISABLED = ( + "Direct messages are disable in your configuration.\n" + "If you want to receive messages from Bots, " + "you need to enable this option under Privacy & Safety:" + '"Allow direct messages from server members."' ) -################################# -# EVENT ON GUILD UPDATE -################################# -NEW_SERVER_SETTINGS = "New Server Settings" -NEW_SERVER_ICON = "New Server Icon" -NEW_SERVER_NAME = "New Server Name" -PREVIOUS_NAME = "Previous Name" -PREVIOUS_SERVER_OWNER = "Previous Server Owner" -NEW_SERVER_OWNER = "New Server Owner" -################################# -# EVENT ON MEMBER JOIN -################################# -JOINED_THE_SERVER = "Joined the Server" -################################# -# EVENT ON MEMBER REMOVE -################################# -LEFT_THE_SERVER = "Left the Server" -################################# -# EVENT ON MEMBER UPDATE -################################# -PROFILE_CHANGES = "Profile Changes" -PREVIOUS_NICKNAME = "Previous Nickname" -NEW_NICKNAME = "New Nickname" -PREVIOUS_ROLES = "Previous Roles" -NEW_ROLES = "New Roles" -################################# -# EVENT ON MESSAGES -################################# -BOT_REACT_EMOJIS = ":rage: :middle_finger:" -OWNER_DM_BOT_MESSAGE = "Hello master.\nWhat can i do for you?" -NO_DM_MESSAGES = "Hello, I don't accept direct messages." -DM_COMMAND_NOT_ALLOWED = "Commands are not allowed in direct messages." -DM_COMMANDS_ALLOW_LIST = "Commands allowed in direct messages" -BOT_REACT_STUPID = "I'm not stupid, fu ufk!!!" -BOT_REACT_RETARD = "I'm not retard, fu ufk!!!" -MESSAGE_CENSURED = "Your message was censored.\nPlease don't say offensive words in this channel." -PRIVATE_BOT_MESSAGE = ( - "This is a Private Bot.\n" - "You are not allowed to execute any commands.\n" - "Only a few users are allowed to use it.\n" - "Please don't insist. Thank You!!!" -) - - -def blocked_invis_message(guild_name: str) -> str: - return ( - "You are Invisible (offline)\n" - f'Server "{guild_name}" does not allow messages from invisible members.\n' - "Please change your status if you want to send messages to this server." +class GuildJoin: + @staticmethod + def bot_message(bot_name: str, prefix: str, games_included: str) -> str: + return ( + f"Thanks for using *{bot_name}*\n" + f"To learn more about this bot: `{prefix}about`\n" + f"Games included so far: `{games_included}`\n\n" + f"If you are an Admin and wish to list configurations: `{prefix}config list`\n" + f"To get a list of commands: `{prefix}help`" + ) + + +class GuildUpdate: + NEW_SERVER_SETTINGS = "New Server Settings" + NEW_SERVER_ICON = "New Server Icon" + NEW_SERVER_NAME = "New Server Name" + PREVIOUS_NAME = "Previous Name" + PREVIOUS_SERVER_OWNER = "Previous Server Owner" + NEW_SERVER_OWNER = "New Server Owner" + + +class MemberJoin: + JOINED_THE_SERVER = "Joined the Server" + + +class MemberRemove: + LEFT_THE_SERVER = "Left the Server" + + +class MemberUpdate: + PROFILE_CHANGES = "Profile Changes" + PREVIOUS_NICKNAME = "Previous Nickname" + NEW_NICKNAME = "New Nickname" + PREVIOUS_ROLES = "Previous Roles" + NEW_ROLES = "New Roles" + + +class Messages: + BOT_REACT_EMOJIS = ":rage: :middle_finger:" + OWNER_DM_BOT_MESSAGE = "Hello master.\nWhat can i do for you?" + NO_DM_MESSAGES = "Hello, I don't accept direct messages." + DM_COMMAND_NOT_ALLOWED = "Commands are not allowed in direct messages." + DM_COMMANDS_ALLOW_LIST = "Commands allowed in direct messages" + BOT_REACT_STUPID = "I'm not stupid, fu ufk!!!" + BOT_REACT_RETARD = "I'm not retard, fu ufk!!!" + MESSAGE_CENSURED = "Your message was censored.\nPlease don't say offensive words in this channel." + PRIVATE_BOT_MESSAGE = ( + "This is a Private Bot.\n" + "You are not allowed to execute any commands.\n" + "Only a few users are allowed to use it.\n" + "Please don't insist. Thank You!!!" ) - -################################# -# EVENT ON USER UPDATE -################################# -NEW_AVATAR = "New Avatar" -NEW_NAME = "New Name" -PREVIOUS_DISCRIMINATOR = "Previous Discriminator" -NEW_DISCRIMINATOR = "New Discriminator" -################################# -# BOT UTILS -################################# -LOADING_EXTENSIONS = "Loading Bot Extensions..." -LOADING_EXTENSION_FAILED = "ERROR: FAILED to load extension" -DISABLED_DM = ( - "Direct messages are disable in your configuration.\n" - "If you want to receive messages from Bots, " - "you need to enable this option under Privacy & Safety:\n" - '"Allow direct messages from server members."\n' -) -MESSAGE_REMOVED_FOR_PRIVACY = "Your message was removed for privacy." -DELETE_MESSAGE_NO_PERMISSION = "Bot does not have permission to delete messages." -################################# -# DICE ROLLS -################################# -DICE_SIZE_NOT_VALID = "Thats not a valid dice size.\nPlease try again." -MEMBER_HIGHEST_ROLL_ANOUNCE = ":star2: This is now your highest roll :star2:" -SERVER_HIGHEST_ROLL_ANOUNCE = ":crown: This is now the server highest roll :crown:" -MEMBER_SERVER_WINNER_ANOUNCE = ":crown: You are the server winner with" -MEMBER_HIGHEST_ROLL = "Your highest roll is now:" -MEMBER_HAS_HIGHEST_ROLL = "has the server highest roll with" -DICE_SIZE_HIGHER_ONE = "Dice size needs to be higher than 1" -RESET_ALL_ROLLS = "Reset all rolls from this server" -DELETED_ALL_ROLLS = "Rolls from all members in this server have been deleted." - - -def no_dice_size_rolls(dice_size) -> str: - return f"There are no dice rolls of the size {dice_size} in this server." - - -################################# -# MISC -################################# -PEPE_DOWNLOAD_ERROR = "Could not download pepe file..." -INVITE_TITLE = "Invite Links" -UNLIMITED_INVITES = "Unlimited Invites" -TEMPORARY_INVITES = "Temporary Invites" -REVOKED_INVITES = "Revoked Invites" -NO_INVITES = "No current invites on any channel." -DO_NOT_DISTURB = "Do Not Disturb" -JOINED_DISCORD_ON = "Joined Discord on" -JOINED_THIS_SERVER_ON = "Joined this server on" -LIST_COMMAND_CATEGORIES = "For a list of command categories" - - -def dev_info_msg(webpage_url: str, discordpy_url: str) -> str: - return ( - f"Developed as an open source project and hosted on [GitHub]({webpage_url})\n" - f"A python discord api wrapper: [discord.py]({discordpy_url})\n" + @staticmethod + def blocked_invis(guild_name: str) -> str: + return ( + "You are Invisible (offline)\n" + f'Server "{guild_name}" does not allow messages from invisible members.\n' + "Please change your status if you want to send messages to this server." + ) + + +class UserUpdate: + NEW_AVATAR = "New Avatar" + NEW_NAME = "New Name" + PREVIOUS_DISCRIMINATOR = "Previous Discriminator" + NEW_DISCRIMINATOR = "New Discriminator" + + +class BotUtils: + LOADING_EXTENSIONS = "Loading Bot Extensions..." + LOADING_EXTENSION_FAILED = "ERROR: FAILED to load extension" + DISABLED_DM = ( + "Direct messages are disable in your configuration.\n" + "If you want to receive messages from Bots, " + "you need to enable this option under Privacy & Safety:\n" + '"Allow direct messages from server members."\n' ) - - -################################# -# OWNER -################################# -BOT_PREFIX_CHANGED = "Bot prefix has been changed to" -BOT_DESCRIPTION_CHANGED = "Bot description changed to" + MESSAGE_REMOVED_FOR_PRIVACY = "Your message was removed for privacy." + DELETE_MESSAGE_NO_PERMISSION = "Bot does not have permission to delete messages." + + +class DiceRolls: + SIZE_NOT_VALID = "Thats not a valid dice size.\nPlease try again." + MEMBER_HIGHEST_ROLL_ANNOUNCE = ":star2: This is now your highest roll :star2:" + SERVER_HIGHEST_ROLL_ANNOUNCE = ":crown: This is now the server highest roll :crown:" + MEMBER_SERVER_WINNER_ANNOUNCE = ":crown: You are the server winner with" + MEMBER_HIGHEST_ROLL = "Your highest roll is now:" + MEMBER_HAS_HIGHEST_ROLL = "has the server highest roll with" + SIZE_HIGHER_ONE = "Dice size needs to be higher than 1" + RESET_ALL = "Reset all rolls from this server" + DELETED_ALL = "Rolls from all members in this server have been deleted." + + @staticmethod + def no_size_rolls(dice_size) -> str: + return f"There are no dice rolls of the size {dice_size} in this server." + + +class Misc: + PEPE_DOWNLOAD_ERROR = "Could not download pepe file..." + INVITE_TITLE = "Invite Links" + UNLIMITED_INVITES = "Unlimited Invites" + TEMPORARY_INVITES = "Temporary Invites" + REVOKED_INVITES = "Revoked Invites" + NO_INVITES = "No current invites on any channel." + DO_NOT_DISTURB = "Do Not Disturb" + JOINED_DISCORD_ON = "Joined Discord on" + JOINED_THIS_SERVER_ON = "Joined this server on" + LIST_COMMAND_CATEGORIES = "For a list of command categories" + + @staticmethod + def dev_info(webpage_url: str, discordpy_url: str) -> str: + return ( + f"Developed as an open source project and hosted on [GitHub]({webpage_url})\n" + f"A python discord api wrapper: [discord.py]({discordpy_url})\n" + ) + + +class Owner: + PREFIX_CHANGED = "Bot prefix has been changed to" + DESCRIPTION_CHANGED = "Bot description changed to" + + +# ============================================================================ +# Backward-compatible module-level aliases +# All existing code using `messages.FOO` continues to work unchanged. +# ============================================================================ + +# Bot +BOT_TOKEN_NOT_FOUND = Bot.TOKEN_NOT_FOUND +BOT_TERMINATED = Bot.TERMINATED +BOT_STOPPED_CTRTC = Bot.STOPPED_CTRTC +BOT_FATAL_ERROR_MAIN = Bot.FATAL_ERROR_MAIN +BOT_CRASHED = Bot.CRASHED +BOT_CLOSING = Bot.CLOSING +BOT_LOGIN_FAILED = Bot.LOGIN_FAILED +BOT_INIT_PREFIX_FAILED = Bot.INIT_PREFIX_FAILED +BOT_LOAD_SETTINGS_FAILED = Bot.LOAD_SETTINGS_FAILED +BOT_LOAD_COGS_FAILED = Bot.LOAD_COGS_FAILED +BOT_LOADED_ALL_COGS_SUCCESS = Bot.LOADED_ALL_COGS_SUCCESS +bot_online = Bot.online +bot_starting = Bot.starting +bot_disconnected = Bot.disconnected + +# Admin +bot_announce_playing = Admin.announce_playing +bg_task_warning = Admin.bg_task_warning + +# Config +CONFIG_JOIN = Config.JOIN +CONFIG_LEAVE = Config.LEAVE +CONFIG_SERVER = Config.SERVER +CONFIG_MEMBER = Config.MEMBER +CONFIG_BLOCK_INVIS_MEMBERS = Config.BLOCK_INVIS_MEMBERS +CONFIG_BOT_WORD_REACTIONS = Config.BOT_WORD_REACTIONS +CONFIG_PFILTER_CHANNELS = Config.PFILTER_CHANNELS +config_pfilter = Config.pfilter +CONFIG_CHANNEL_ID_INSTEAD_NAME = Config.CHANNEL_ID_INSTEAD_NAME +CONFIG_NOT_ACTIVATED_ERROR = Config.NOT_ACTIVATED_ERROR +MISING_REUIRED_ARGUMENT = Config.MISSING_REQUIRED_ARGUMENT +CHANNEL_ID_NOT_FOUND = Config.CHANNEL_ID_NOT_FOUND +BOT_MISSING_MANAGE_MESSAGES_PERMISSION = Config.BOT_MISSING_MANAGE_MESSAGES +NO_CHANNELS_LISTED = Config.NO_CHANNELS_LISTED + +# Custom Command +ALREADY_A_STANDARD_COMMAND = CustomCommand.ALREADY_A_STANDARD_COMMAND +COMMAND_LENGHT_ERROR = CustomCommand.LENGTH_ERROR +CUSTOM_COMMAND_ADDED = CustomCommand.ADDED +CUSTOM_COMMAND_EDITED = CustomCommand.EDITED +CUSTOM_COMMAND_REMOVED = CustomCommand.REMOVED +CUSTOM_COMMAND_ALL_REMOVED = CustomCommand.ALL_REMOVED +COMMAND_ALREADY_EXISTS = CustomCommand.ALREADY_EXISTS +NO_CUSTOM_COMMANDS_FOUND = CustomCommand.NO_COMMANDS_FOUND +CUSTOM_COMMAND_UNABLE_REMOVE = CustomCommand.UNABLE_REMOVE +CUSTOM_COMMANDS_SERVER = CustomCommand.COMMANDS_SERVER +GET_CONFIGS_ERROR = CustomCommand.GET_CONFIGS_ERROR + +# Command Error +MISSING_REQUIRED_ARGUMENT_HELP_MESSAGE = CommandError.MISSING_REQUIRED_ARGUMENT_HELP +COMMAND_NOT_FOUND = CommandError.NOT_FOUND +COMMAND_ERROR = CommandError.ERROR +COMMAND_RAISED_EXCEPTION = CommandError.RAISED_EXCEPTION +NOT_ADMIN_USE_COMMAND = CommandError.NOT_ADMIN +BOT_OWNERS_ONLY_COMMAND = CommandError.OWNERS_ONLY +PREFIXES_CHOICE = CommandError.PREFIXES_CHOICE +MORE_INFO = CommandError.MORE_INFO +UNKNOWN_OPTION = CommandError.UNKNOWN_OPTION +HELP_COMMAND_MORE_INFO = CommandError.HELP_MORE_INFO +NO_OPTION_FOUND = CommandError.NO_OPTION_FOUND +NO_PERMISSION_EXECUTE_COMMAND = CommandError.NO_PERMISSION +INVALID_MESSAGE = CommandError.INVALID_MESSAGE +COMMAND_INTERNAL_ERROR = CommandError.INTERNAL_ERROR +DM_CANNOT_EXECUTE_COMMAND = CommandError.DM_CANNOT_EXECUTE +PRIVILEGE_LOW = CommandError.PRIVILEGE_LOW +DIRECT_MESSAGES_DISABLED = CommandError.DIRECT_MESSAGES_DISABLED + +# Guild Join +guild_join_bot_message = GuildJoin.bot_message + +# Guild Update +NEW_SERVER_SETTINGS = GuildUpdate.NEW_SERVER_SETTINGS +NEW_SERVER_ICON = GuildUpdate.NEW_SERVER_ICON +NEW_SERVER_NAME = GuildUpdate.NEW_SERVER_NAME +PREVIOUS_NAME = GuildUpdate.PREVIOUS_NAME +PREVIOUS_SERVER_OWNER = GuildUpdate.PREVIOUS_SERVER_OWNER +NEW_SERVER_OWNER = GuildUpdate.NEW_SERVER_OWNER + +# Member Join +JOINED_THE_SERVER = MemberJoin.JOINED_THE_SERVER + +# Member Remove +LEFT_THE_SERVER = MemberRemove.LEFT_THE_SERVER + +# Member Update +PROFILE_CHANGES = MemberUpdate.PROFILE_CHANGES +PREVIOUS_NICKNAME = MemberUpdate.PREVIOUS_NICKNAME +NEW_NICKNAME = MemberUpdate.NEW_NICKNAME +PREVIOUS_ROLES = MemberUpdate.PREVIOUS_ROLES +NEW_ROLES = MemberUpdate.NEW_ROLES + +# Messages +BOT_REACT_EMOJIS = Messages.BOT_REACT_EMOJIS +OWNER_DM_BOT_MESSAGE = Messages.OWNER_DM_BOT_MESSAGE +NO_DM_MESSAGES = Messages.NO_DM_MESSAGES +DM_COMMAND_NOT_ALLOWED = Messages.DM_COMMAND_NOT_ALLOWED +DM_COMMANDS_ALLOW_LIST = Messages.DM_COMMANDS_ALLOW_LIST +BOT_REACT_STUPID = Messages.BOT_REACT_STUPID +BOT_REACT_RETARD = Messages.BOT_REACT_RETARD +MESSAGE_CENSURED = Messages.MESSAGE_CENSURED +PRIVATE_BOT_MESSAGE = Messages.PRIVATE_BOT_MESSAGE +blocked_invis_message = Messages.blocked_invis + +# User Update +NEW_AVATAR = UserUpdate.NEW_AVATAR +NEW_NAME = UserUpdate.NEW_NAME +PREVIOUS_DISCRIMINATOR = UserUpdate.PREVIOUS_DISCRIMINATOR +NEW_DISCRIMINATOR = UserUpdate.NEW_DISCRIMINATOR + +# Bot Utils +LOADING_EXTENSIONS = BotUtils.LOADING_EXTENSIONS +LOADING_EXTENSION_FAILED = BotUtils.LOADING_EXTENSION_FAILED +DISABLED_DM = BotUtils.DISABLED_DM +MESSAGE_REMOVED_FOR_PRIVACY = BotUtils.MESSAGE_REMOVED_FOR_PRIVACY +DELETE_MESSAGE_NO_PERMISSION = BotUtils.DELETE_MESSAGE_NO_PERMISSION + +# Dice Rolls +DICE_SIZE_NOT_VALID = DiceRolls.SIZE_NOT_VALID +MEMBER_HIGHEST_ROLL_ANOUNCE = DiceRolls.MEMBER_HIGHEST_ROLL_ANNOUNCE +SERVER_HIGHEST_ROLL_ANOUNCE = DiceRolls.SERVER_HIGHEST_ROLL_ANNOUNCE +MEMBER_SERVER_WINNER_ANOUNCE = DiceRolls.MEMBER_SERVER_WINNER_ANNOUNCE +MEMBER_HIGHEST_ROLL = DiceRolls.MEMBER_HIGHEST_ROLL +MEMBER_HAS_HIGHEST_ROLL = DiceRolls.MEMBER_HAS_HIGHEST_ROLL +DICE_SIZE_HIGHER_ONE = DiceRolls.SIZE_HIGHER_ONE +RESET_ALL_ROLLS = DiceRolls.RESET_ALL +DELETED_ALL_ROLLS = DiceRolls.DELETED_ALL +no_dice_size_rolls = DiceRolls.no_size_rolls + +# Misc +PEPE_DOWNLOAD_ERROR = Misc.PEPE_DOWNLOAD_ERROR +INVITE_TITLE = Misc.INVITE_TITLE +UNLIMITED_INVITES = Misc.UNLIMITED_INVITES +TEMPORARY_INVITES = Misc.TEMPORARY_INVITES +REVOKED_INVITES = Misc.REVOKED_INVITES +NO_INVITES = Misc.NO_INVITES +DO_NOT_DISTURB = Misc.DO_NOT_DISTURB +JOINED_DISCORD_ON = Misc.JOINED_DISCORD_ON +JOINED_THIS_SERVER_ON = Misc.JOINED_THIS_SERVER_ON +LIST_COMMAND_CATEGORIES = Misc.LIST_COMMAND_CATEGORIES +dev_info_msg = Misc.dev_info + +# Owner +BOT_PREFIX_CHANGED = Owner.PREFIX_CHANGED +BOT_DESCRIPTION_CHANGED = Owner.DESCRIPTION_CHANGED diff --git a/src/database/migrations/versions/0011_drop_unique_session_chars_name.py b/src/database/migrations/versions/0011_drop_unique_session_chars_name.py new file mode 100644 index 00000000..beebcd78 --- /dev/null +++ b/src/database/migrations/versions/0011_drop_unique_session_chars_name.py @@ -0,0 +1,24 @@ +"""drop unique constraint on gw2_session_chars.name + +Revision ID: 0011 +Revises: 0010 +Create Date: 2026-02-24 00:00:00.000000 + +""" + +from alembic import op +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "0011" +down_revision: str | None = "0010" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_constraint("gw2_session_chars_name_key", "gw2_session_chars", type_="unique") + + +def downgrade() -> None: + op.create_unique_constraint("gw2_session_chars_name_key", "gw2_session_chars", ["name"]) diff --git a/src/database/models/gw2_models.py b/src/database/models/gw2_models.py index 453ef3f7..498e7b50 100644 --- a/src/database/models/gw2_models.py +++ b/src/database/models/gw2_models.py @@ -41,7 +41,7 @@ class Gw2SessionChars(BotBase): id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) session_id: Mapped[int] = mapped_column(BigInteger, ForeignKey(Gw2Sessions.id)) user_id: Mapped[int] = mapped_column(BigInteger) - name: Mapped[str] = mapped_column(unique=True) + name: Mapped[str] = mapped_column() profession: Mapped[str] = mapped_column() deaths: Mapped[int] = mapped_column() start: Mapped[Boolean] = mapped_column(Boolean) diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index efc539ca..521a2764 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -7,6 +7,7 @@ from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal from src.gw2.cogs.gw2 import GuildWars2 from src.gw2.constants import gw2_messages +from src.gw2.constants.gw2_currencies import WALLET_DISPLAY_NAMES from src.gw2.tools import gw2_utils from src.gw2.tools.gw2_cooldowns import GW2CoolDowns @@ -113,107 +114,32 @@ async def session(ctx): ) embed.add_field(name=gw2_messages.ACCOUNT_NAME, value=chat_formatting.inline(acc_name)) embed.add_field(name=gw2_messages.SERVER, value=chat_formatting.inline(gw2_server)) - embed.add_field(name=gw2_messages.TOTAL_PLAYED_TIME, value=chat_formatting.inline(str(time_passed.timedelta))) - if rs_start["gold"] != rs_end["gold"]: - full_gold = str(rs_end["gold"] - rs_start["gold"]) - formatted_gold = gw2_utils.format_gold(full_gold) - if int(full_gold) > 0: - embed.add_field( - name=gw2_messages.GAINED_GOLD, - value=chat_formatting.inline(f"+{formatted_gold}"), - inline=False, - ) - elif int(full_gold) < 0: - final_result = f"{formatted_gold}" - if formatted_gold[0] != "-": - final_result = f"-{formatted_gold}" - embed.add_field(name=gw2_messages.LOST_GOLD, value=chat_formatting.inline(str(final_result)), inline=False) + # Play time from API age (actual in-game time) + start_age = rs_start.get("age", 0) + end_age = rs_end.get("age", 0) + play_time_seconds = end_age - start_age + if play_time_seconds > 0: + play_time_str = gw2_utils.format_seconds_to_time(play_time_seconds) + else: + play_time_str = str(time_passed.timedelta) + embed.add_field(name=gw2_messages.PLAY_TIME, value=chat_formatting.inline(play_time_str)) + + # Gold (special formatting) + _add_gold_field(embed, rs_start, rs_end) + # Deaths gw2_session_chars_dal = Gw2SessionCharsDal(ctx.bot.db_session, ctx.bot.log) rs_chars_start = await gw2_session_chars_dal.get_all_start_characters(user_id) if rs_chars_start: rs_chars_end = await gw2_session_chars_dal.get_all_end_characters(user_id) - prof_names = "" - total_deaths = 0 - - for char_start in rs_chars_start: - for char_end in rs_chars_end: - if char_start["name"] == char_end["name"]: - if char_start["deaths"] != char_end["deaths"]: - name = char_start["name"] - profession = char_start["profession"] - time_deaths = int(char_end["deaths"]) - int(char_start["deaths"]) - total_deaths += time_deaths - prof_names += f"({profession}:{name}:{time_deaths})" - - if len(prof_names) > 0: - deaths_msg = f"{prof_names} [Total:{total_deaths}]" - embed.add_field(name=gw2_messages.TIMES_YOU_DIED, value=chat_formatting.inline(deaths_msg), inline=False) - - if rs_start["karma"] != rs_end["karma"]: - final_result = str(rs_end["karma"] - rs_start["karma"]) - field_name = gw2_messages.GAINED_KARMA if int(final_result) > 0 else gw2_messages.LOST_KARMA - embed.add_field(name=field_name, value=chat_formatting.inline(f"+{final_result}")) - - if rs_start["laurels"] != rs_end["laurels"]: - final_result = str(rs_end["laurels"] - rs_start["laurels"]) - field_name = gw2_messages.GAINED_LAURElS if int(final_result) > 0 else gw2_messages.LOST_LAURElS - embed.add_field(name=field_name, value=chat_formatting.inline(f"+{final_result}")) - - if rs_start["wvw_rank"] != rs_end["wvw_rank"]: - final_result = str(rs_end["wvw_rank"] - rs_start["wvw_rank"]) - embed.add_field(name=gw2_messages.GAINED_WVW_RANKS, value=chat_formatting.inline(str(final_result))) - - if rs_start["yaks"] != rs_end["yaks"]: - final_result = str(rs_end["yaks"] - rs_start["yaks"]) - embed.add_field(name=gw2_messages.YAKS_KILLED, value=chat_formatting.inline(str(final_result))) - - if rs_start["yaks_scorted"] != rs_end["yaks_scorted"]: - final_result = str(rs_end["yaks_scorted"] - rs_start["yaks_scorted"]) - embed.add_field(name=gw2_messages.YAKS_SCORTED, value=chat_formatting.inline(str(final_result))) - - if rs_start["players"] != rs_end["players"]: - final_result = str(rs_end["players"] - rs_start["players"]) - embed.add_field(name=gw2_messages.PLAYERS_KILLED, value=chat_formatting.inline(str(final_result))) - - if rs_start["keeps"] != rs_end["keeps"]: - final_result = str(rs_end["keeps"] - rs_start["keeps"]) - embed.add_field(name=gw2_messages.KEEPS_CAPTURED, value=chat_formatting.inline(str(final_result))) - - if rs_start["towers"] != rs_end["towers"]: - final_result = str(rs_end["towers"] - rs_start["towers"]) - embed.add_field(name=gw2_messages.TOWERS_CAPTURED, value=chat_formatting.inline(str(final_result))) - - if rs_start["camps"] != rs_end["camps"]: - final_result = str(rs_end["camps"] - rs_start["camps"]) - embed.add_field(name=gw2_messages.CAMPS_CAPTURED, value=chat_formatting.inline(str(final_result))) - - if rs_start["castles"] != rs_end["castles"]: - final_result = str(rs_end["castles"] - rs_start["castles"]) - embed.add_field(name=gw2_messages.SMC_CAPTURED, value=chat_formatting.inline(str(final_result))) - - if rs_start["wvw_tickets"] != rs_end["wvw_tickets"]: - final_result = str(rs_end["wvw_tickets"] - rs_start["wvw_tickets"]) - field_name = gw2_messages.GAINED_WVW_TICKETS if int(final_result) > 0 else gw2_messages.LOST_WVW_TICKETS - embed.add_field(name=field_name, value=chat_formatting.inline(f"+{final_result}")) - - if rs_start["proof_heroics"] != rs_end["proof_heroics"]: - final_result = str(rs_end["proof_heroics"] - rs_start["proof_heroics"]) - field_name = gw2_messages.GAINED_PROOF_HEROICS if int(final_result) > 0 else gw2_messages.LOST_PROOF_HEROICS - embed.add_field(name=field_name, value=chat_formatting.inline(f"+{final_result}")) - - if rs_start["badges_honor"] != rs_end["badges_honor"]: - final_result = str(rs_end["badges_honor"] - rs_start["badges_honor"]) - field_name = gw2_messages.GAINED_BADGES_HONOR if int(final_result) > 0 else gw2_messages.LOST_BADGES_HONOR - embed.add_field(name=field_name, value=chat_formatting.inline(f"+{final_result}")) - - if rs_start["guild_commendations"] != rs_end["guild_commendations"]: - final_result = str(rs_end["guild_commendations"] - rs_start["guild_commendations"]) - field_name = ( - gw2_messages.GAINED_GUILD_COMMENDATIONS if int(final_result) > 0 else gw2_messages.LOST_GUILD_COMMENDATIONS - ) - embed.add_field(name=field_name, value=chat_formatting.inline(f"+{final_result}")) + _add_deaths_field(embed, rs_chars_start, rs_chars_end) + + # WvW achievement-based stats + _add_wvw_stats(embed, rs_start, rs_end) + + # All wallet currencies (except gold, handled above) + _add_wallet_currency_fields(embed, rs_start, rs_end) if ( not (isinstance(ctx.channel, discord.DMChannel)) @@ -230,6 +156,87 @@ async def session(ctx): return None +def _add_gold_field(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None: + """Add gold gained/lost field to embed.""" + start_gold = rs_start.get("gold", 0) + end_gold = rs_end.get("gold", 0) + if start_gold != end_gold: + diff = end_gold - start_gold + full_gold = str(diff) + formatted_gold = gw2_utils.format_gold(full_gold) + if diff > 0: + embed.add_field( + name="Gained Gold", + value=chat_formatting.inline(f"+{formatted_gold}"), + inline=False, + ) + elif diff < 0: + final_result = f"{formatted_gold}" + if formatted_gold[0] != "-": + final_result = f"-{formatted_gold}" + embed.add_field(name="Lost Gold", value=chat_formatting.inline(str(final_result)), inline=False) + + +def _add_deaths_field(embed: discord.Embed, rs_chars_start: list[dict], rs_chars_end: list[dict]) -> None: + """Add deaths field to embed.""" + if not rs_chars_end: + return + + prof_names = "" + total_deaths = 0 + + for char_start in rs_chars_start: + for char_end in rs_chars_end: + if char_start["name"] == char_end["name"]: + if char_start["deaths"] != char_end["deaths"]: + name = char_start["name"] + profession = char_start["profession"] + time_deaths = int(char_end["deaths"]) - int(char_start["deaths"]) + total_deaths += time_deaths + prof_names += f"({profession}:{name}:{time_deaths})" + + if len(prof_names) > 0: + deaths_msg = f"{prof_names} [Total:{total_deaths}]" + embed.add_field(name=gw2_messages.TIMES_YOU_DIED, value=chat_formatting.inline(deaths_msg), inline=False) + + +def _add_wvw_stats(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None: + """Add WvW achievement-based stats to embed.""" + wvw_fields = [ + ("wvw_rank", gw2_messages.GAINED_WVW_RANKS), + ("yaks", gw2_messages.YAKS_KILLED), + ("yaks_scorted", gw2_messages.YAKS_SCORTED), + ("players", gw2_messages.PLAYERS_KILLED), + ("keeps", gw2_messages.KEEPS_CAPTURED), + ("towers", gw2_messages.TOWERS_CAPTURED), + ("camps", gw2_messages.CAMPS_CAPTURED), + ("castles", gw2_messages.SMC_CAPTURED), + ] + + for stat_key, field_name in wvw_fields: + start_val = rs_start.get(stat_key, 0) + end_val = rs_end.get(stat_key, 0) + if start_val != end_val: + diff = end_val - start_val + embed.add_field(name=field_name, value=chat_formatting.inline(str(diff))) + + +def _add_wallet_currency_fields(embed: discord.Embed, rs_start: dict, rs_end: dict) -> None: + """Add wallet currency fields to embed (all except gold, which has special formatting).""" + for stat_key, display_name in WALLET_DISPLAY_NAMES.items(): + if stat_key == "gold": + continue + + start_val = rs_start.get(stat_key, 0) + end_val = rs_end.get(stat_key, 0) + if start_val != end_val: + diff = end_val - start_val + if diff > 0: + embed.add_field(name=f"Gained {display_name}", value=chat_formatting.inline(f"+{diff}")) + else: + embed.add_field(name=f"Lost {display_name}", value=chat_formatting.inline(str(diff))) + + async def setup(bot): bot.remove_command("gw2") await bot.add_cog(GW2Session(bot)) diff --git a/src/gw2/constants/gw2_currencies.py b/src/gw2/constants/gw2_currencies.py new file mode 100644 index 00000000..92c8fec8 --- /dev/null +++ b/src/gw2/constants/gw2_currencies.py @@ -0,0 +1,184 @@ +"""GW2 API currency and achievement ID mappings. + +Wallet IDs from: https://api.guildwars2.com/v2/currencies?ids=all +Achievement IDs from: https://api.guildwars2.com/v2/achievements +""" + +from typing import Final + +# Mapping of GW2 API wallet currency IDs to internal stat names +WALLET_MAPPING: Final[dict[int, str]] = { + 1: "gold", + 2: "karma", + 3: "laurels", + 4: "gems", + 5: "ascalonian_tears", + 6: "shards_of_zhaitan", + 7: "fractal_relics", + 9: "seals_of_beetletun", + 10: "manifesto_of_the_moletariate", + 11: "deadly_blooms", + 12: "symbols_of_koda", + 13: "flame_legion_charr_carvings", + 14: "knowledge_crystals", + 15: "badges_honor", + 16: "guild_commendations", + 18: "transmutation_charges", + 19: "airship_parts", + 20: "ley_line_crystals", + 22: "lumps_of_aurillium", + 23: "spirit_shards", + 24: "pristine_fractal_relics", + 25: "geodes", + 26: "wvw_tickets", + 27: "bandit_crests", + 28: "magnetite_shards", + 29: "provisioner_tokens", + 30: "pvp_league_tickets", + 31: "proof_heroics", + 32: "unbound_magic", + 33: "ascended_shards_of_glory", + 34: "trade_contracts", + 35: "elegy_mosaics", + 36: "test_heroics", + 37: "exalted_keys", + 38: "machetes", + 39: "gaeting_crystals", + 40: "bandit_skeleton_keys", + 41: "pact_crowbars", + 42: "vials_of_chak_acid", + 43: "zephyrite_lockpicks", + 44: "traders_keys", + 45: "volatile_magic", + 46: "pvp_tournament_vouchers", + 47: "racing_medallions", + 49: "mistborn_keys", + 50: "festival_tokens", + 51: "cache_keys", + 52: "red_prophet_shards", + 53: "green_prophet_shards", + 54: "blue_prophet_crystals", + 55: "green_prophet_crystals", + 56: "red_prophet_crystals", + 57: "blue_prophet_shards", + 58: "war_supplies", + 59: "unstable_fractal_essences", + 60: "tyrian_defense_seals", + 61: "research_notes", + 62: "unusual_coins", + 63: "astral_acclaim", + 64: "jade_slivers", + 65: "testimony_of_jade_heroics", + 66: "ancient_coins", + 67: "canach_coins", + 68: "imperial_favors", + 69: "tales_of_dungeon_delving", + 70: "legendary_insights", + 71: "jade_miner_keycards", + 72: "static_charges", + 73: "pinch_of_stardust", + 74: "unnamed_currency_74", + 75: "calcified_gasps", + 76: "ursus_oblige", + 77: "gaeting_crystals_2", + 78: "fine_rift_essences", + 79: "rare_rift_essences", + 80: "masterwork_rift_essences", + 81: "antiquated_ducats", + 82: "testimony_of_castoran_heroics", + 83: "aether_rich_sap", +} + +# Human-readable display names for wallet currencies +WALLET_DISPLAY_NAMES: Final[dict[str, str]] = { + "gold": "Gold", + "karma": "Karma", + "laurels": "Laurels", + "gems": "Gems", + "ascalonian_tears": "Ascalonian Tears", + "shards_of_zhaitan": "Shards of Zhaitan", + "fractal_relics": "Fractal Relics", + "seals_of_beetletun": "Seals of Beetletun", + "manifesto_of_the_moletariate": "Manifesto of the Moletariate", + "deadly_blooms": "Deadly Blooms", + "symbols_of_koda": "Symbols of Koda", + "flame_legion_charr_carvings": "Flame Legion Charr Carvings", + "knowledge_crystals": "Knowledge Crystals", + "badges_honor": "Badges of Honor", + "guild_commendations": "Guild Commendations", + "transmutation_charges": "Transmutation Charges", + "airship_parts": "Airship Parts", + "ley_line_crystals": "Ley Line Crystals", + "lumps_of_aurillium": "Lumps of Aurillium", + "spirit_shards": "Spirit Shards", + "pristine_fractal_relics": "Pristine Fractal Relics", + "geodes": "Geodes", + "wvw_tickets": "WvW Skirmish Tickets", + "bandit_crests": "Bandit Crests", + "magnetite_shards": "Magnetite Shards", + "provisioner_tokens": "Provisioner Tokens", + "pvp_league_tickets": "PvP League Tickets", + "proof_heroics": "Proof of Heroics", + "unbound_magic": "Unbound Magic", + "ascended_shards_of_glory": "Ascended Shards of Glory", + "trade_contracts": "Trade Contracts", + "elegy_mosaics": "Elegy Mosaics", + "test_heroics": "Testimony of Desert Heroics", + "exalted_keys": "Exalted Keys", + "machetes": "Machetes", + "gaeting_crystals": "Gaeting Crystals", + "bandit_skeleton_keys": "Bandit Skeleton Keys", + "pact_crowbars": "Pact Crowbars", + "vials_of_chak_acid": "Vials of Chak Acid", + "zephyrite_lockpicks": "Zephyrite Lockpicks", + "traders_keys": "Trader's Keys", + "volatile_magic": "Volatile Magic", + "pvp_tournament_vouchers": "PvP Tournament Vouchers", + "racing_medallions": "Racing Medallions", + "mistborn_keys": "Mistborn Keys", + "festival_tokens": "Festival Tokens", + "cache_keys": "Cache Keys", + "red_prophet_shards": "Red Prophet Shards", + "green_prophet_shards": "Green Prophet Shards", + "blue_prophet_crystals": "Blue Prophet Crystals", + "green_prophet_crystals": "Green Prophet Crystals", + "red_prophet_crystals": "Red Prophet Crystals", + "blue_prophet_shards": "Blue Prophet Shards", + "war_supplies": "War Supplies", + "unstable_fractal_essences": "Unstable Fractal Essences", + "tyrian_defense_seals": "Tyrian Defense Seals", + "research_notes": "Research Notes", + "unusual_coins": "Unusual Coins", + "astral_acclaim": "Astral Acclaim", + "jade_slivers": "Jade Slivers", + "testimony_of_jade_heroics": "Testimony of Jade Heroics", + "ancient_coins": "Ancient Coins", + "canach_coins": "Canach Coins", + "imperial_favors": "Imperial Favors", + "tales_of_dungeon_delving": "Tales of Dungeon Delving", + "legendary_insights": "Legendary Insights", + "jade_miner_keycards": "Jade Miner Keycards", + "static_charges": "Static Charges", + "pinch_of_stardust": "Pinch of Stardust", + "unnamed_currency_74": "Unknown Currency", + "calcified_gasps": "Calcified Gasps", + "ursus_oblige": "Ursus Oblige", + "gaeting_crystals_2": "Gaeting Crystals", + "fine_rift_essences": "Fine Rift Essences", + "rare_rift_essences": "Rare Rift Essences", + "masterwork_rift_essences": "Masterwork Rift Essences", + "antiquated_ducats": "Antiquated Ducats", + "testimony_of_castoran_heroics": "Testimony of Castoran Heroics", + "aether_rich_sap": "Aether-Rich Sap", +} + +# Mapping of GW2 API achievement IDs to WvW stat names +ACHIEVEMENT_MAPPING: Final[dict[int, str]] = { + 283: "players", + 285: "yaks_scorted", + 288: "yaks", + 291: "camps", + 294: "castles", + 297: "towers", + 300: "keeps", +} diff --git a/src/gw2/constants/gw2_messages.py b/src/gw2/constants/gw2_messages.py index 55e4a39b..ac357b5c 100644 --- a/src/gw2/constants/gw2_messages.py +++ b/src/gw2/constants/gw2_messages.py @@ -98,30 +98,16 @@ def session_not_active(prefix: str) -> str: WAITING_TIME = "Waiting time" ACCOUNT_NAME = "Account Name" SERVER = "Server" -TOTAL_PLAYED_TIME = "Total played time" -GAINED_GOLD = "Gained gold" -LOST_GOLD = "Lost gold" +PLAY_TIME = "Play time" TIMES_YOU_DIED = "Times you died" -GAINED_KARMA = "Gained karma" -LOST_KARMA = "Lost karma" -GAINED_LAURElS = "Gained laurels" -LOST_LAURElS = "Lost laurels" -GAINED_WVW_RANKS = "Gained wvw ranks" +GAINED_WVW_RANKS = "Gained WvW ranks" YAKS_KILLED = "Yaks killed" -YAKS_SCORTED = "Yaks scorted" +YAKS_SCORTED = "Yaks escorted" PLAYERS_KILLED = "Players killed" KEEPS_CAPTURED = "Keeps captured" TOWERS_CAPTURED = "Towers captured" CAMPS_CAPTURED = "Camps captured" SMC_CAPTURED = "SMC captured" -GAINED_WVW_TICKETS = "Gained wvw tickets" -LOST_WVW_TICKETS = "Lost wvw tickets" -GAINED_PROOF_HEROICS = "Gained proof heroics" -LOST_PROOF_HEROICS = "Lost proof heroics" -GAINED_BADGES_HONOR = "Gained badges of honor" -LOST_BADGES_HONOR = "Lost badges of honor" -GAINED_GUILD_COMMENDATIONS = "Gained guild commendations" -LOST_GUILD_COMMENDATIONS = "Lost guild commendations" SESSION_SAVE_ERROR = ( "There was a problem trying to record your last finished session.\n" "Please, do not close discord when the game is running." diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index 0ea0a3bb..0a443808 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -22,6 +22,7 @@ def __init__(self): from src.database.dal.gw2.gw2_session_chars_dal import Gw2SessionCharsDal from src.database.dal.gw2.gw2_sessions_dal import Gw2SessionsDal from src.gw2.constants import gw2_messages +from src.gw2.constants.gw2_currencies import ACHIEVEMENT_MAPPING, WALLET_MAPPING from src.gw2.constants.gw2_teams import get_team_name, is_wr_team_id from src.gw2.tools.gw2_client import Gw2Client @@ -372,17 +373,10 @@ async def get_user_stats(bot: Bot, api_key: str) -> dict | None: def _create_initial_user_stats(account_data: dict) -> dict: """Create initial user stats structure.""" wvw_rank = account_data.get("wvw", {}).get("rank") or account_data.get("wvw_rank", 0) - return { + stats = { "acc_name": account_data["name"], + "age": account_data.get("age", 0), "wvw_rank": wvw_rank, - "gold": 0, - "karma": 0, - "laurels": 0, - "badges_honor": 0, - "guild_commendations": 0, - "wvw_tickets": 0, - "proof_heroics": 0, - "test_heroics": 0, "players": 0, "yaks_scorted": 0, "yaks": 0, @@ -391,46 +385,26 @@ def _create_initial_user_stats(account_data: dict) -> dict: "towers": 0, "keeps": 0, } + for key in WALLET_MAPPING.values(): + stats[key] = 0 + return stats def _update_wallet_stats(user_stats: dict, wallet_data: list[dict]) -> None: """Update user stats with wallet information.""" - # Mapping of wallet IDs to stat names - wallet_mapping = { - 1: "gold", - 2: "karma", - 3: "laurels", - 15: "badges_honor", - 16: "guild_commendations", - 26: "wvw_tickets", - 31: "proof_heroics", - 36: "test_heroics", - } - for wallet_item in wallet_data: wallet_id = wallet_item["id"] - if wallet_id in wallet_mapping: - stat_name = wallet_mapping[wallet_id] + if wallet_id in WALLET_MAPPING: + stat_name = WALLET_MAPPING[wallet_id] user_stats[stat_name] = wallet_item["value"] def _update_achievement_stats(user_stats: dict, achievements_data: list[dict]) -> None: """Update user stats with achievement information.""" - # Mapping of achievement IDs to stat names - achievement_mapping = { - 283: "players", - 285: "yaks_scorted", - 288: "yaks", - 291: "camps", - 294: "castles", - 297: "towers", - 300: "keeps", - } - for achievement in achievements_data: achievement_id = achievement["id"] - if achievement_id in achievement_mapping: - stat_name = achievement_mapping[achievement_id] + if achievement_id in ACHIEVEMENT_MAPPING: + stat_name = ACHIEVEMENT_MAPPING[achievement_id] user_stats[stat_name] = achievement.get("current", 0) @@ -587,6 +561,28 @@ def get_time_passed(start_time: datetime, end_time: datetime) -> TimeObject: return convert_timedelta_to_obj(time_passed_delta) +def format_seconds_to_time(total_seconds: int) -> str: + """Format seconds into a human-readable time string (e.g. '2h 30m 15s').""" + if total_seconds <= 0: + return "0s" + + days = total_seconds // 86400 + hours = (total_seconds % 86400) // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + + parts = [] + if days > 0: + parts.append(f"{days}d") + if hours > 0: + parts.append(f"{hours}h") + if minutes > 0: + parts.append(f"{minutes}m") + if seconds > 0 or not parts: + parts.append(f"{seconds}s") + return " ".join(parts) + + def convert_timedelta_to_obj(time_delta: timedelta) -> TimeObject: """Convert timedelta to a structured object with individual time components.""" obj = TimeObject() diff --git a/tests/integration/test_alembic_migrations.py b/tests/integration/test_alembic_migrations.py index 01eee84e..e50cd15f 100644 --- a/tests/integration/test_alembic_migrations.py +++ b/tests/integration/test_alembic_migrations.py @@ -97,7 +97,7 @@ async def test_alembic_version_at_head(db_session): text("SELECT version_num FROM alembic_version"), ) assert len(rows) == 1 - assert rows[0]["version_num"] == "0010" + assert rows[0]["version_num"] == "0011" # ────────────────────────────────────────────────────────────────────── @@ -490,7 +490,7 @@ async def test_gw2_sessions_insert_and_read_jsonb(db_session): # ────────────────────────────────────────────────────────────────────── -# 0010 — gw2_session_chars FK + unique name +# 0010/0011 — gw2_session_chars FK (unique name dropped in 0011) # ────────────────────────────────────────────────────────────────────── @@ -508,17 +508,17 @@ async def test_gw2_session_chars_fk_to_sessions(db_session): assert any(r["foreign_table"] == "gw2_sessions" for r in rows) -async def test_gw2_session_chars_unique_name(db_session): +async def test_gw2_session_chars_no_unique_name(db_session): + """Migration 0011 dropped the unique constraint on name to allow start+end records.""" rows = await _fetch_rows( db_session, text( "SELECT constraint_name FROM information_schema.table_constraints " "WHERE table_name = 'gw2_session_chars' AND constraint_type = 'UNIQUE' " - "AND constraint_name != 'gw2_session_chars_pkey'" + "AND constraint_name = 'gw2_session_chars_name_key'" ), ) - # At least the unique(name) and unique(id) constraints - assert len(rows) >= 1 + assert len(rows) == 0 async def test_gw2_session_chars_insert_and_read(db_session): diff --git a/tests/integration/test_gw2_api_public.py b/tests/integration/test_gw2_api_public.py new file mode 100644 index 00000000..60e407fa --- /dev/null +++ b/tests/integration/test_gw2_api_public.py @@ -0,0 +1,124 @@ +"""Integration tests that validate GW2 API currency and achievement ID mappings. + +These tests connect to the public GW2 API (no authentication required) to verify +that our hardcoded ID mappings still exist in the live API. +""" + +import json +import pytest +import urllib.request +from src.gw2.constants.gw2_currencies import ACHIEVEMENT_MAPPING, WALLET_MAPPING + +GW2_API_BASE = "https://api.guildwars2.com/v2" +REQUEST_TIMEOUT = 30 + + +def _fetch_json(url: str) -> dict | list: + """Fetch JSON from a URL using only stdlib.""" + req = urllib.request.Request(url, headers={"User-Agent": "DiscordBot-Tests/1.0"}) + with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp: + return json.loads(resp.read().decode()) + + +@pytest.mark.gw2_api +class TestWalletCurrencyIdsExist: + """Validate that all WALLET_MAPPING IDs exist in the live GW2 API.""" + + @pytest.fixture(scope="class") + def api_currencies_by_id(self): + """Fetch currency data for all our mapped IDs from the GW2 API.""" + ids = ",".join(str(k) for k in sorted(WALLET_MAPPING.keys())) + return {c["id"]: c for c in _fetch_json(f"{GW2_API_BASE}/currencies?ids={ids}")} + + def test_all_wallet_ids_exist_in_api(self, api_currencies_by_id): + missing = set(WALLET_MAPPING.keys()) - set(api_currencies_by_id.keys()) + assert not missing, f"WALLET_MAPPING contains IDs not found in GW2 API: {missing}" + + def test_all_currencies_have_name_field(self, api_currencies_by_id): + for cid, data in api_currencies_by_id.items(): + assert "name" in data, f"Currency {cid} has no name field" + + +@pytest.mark.gw2_api +class TestWalletCurrencyNamesMatch: + """Validate that our currency names roughly match the API names.""" + + @pytest.fixture(scope="class") + def api_currencies(self): + """Fetch full currency data from the GW2 API.""" + ids = ",".join(str(k) for k in sorted(WALLET_MAPPING.keys())) + return {c["id"]: c["name"] for c in _fetch_json(f"{GW2_API_BASE}/currencies?ids={ids}")} + + def test_gold_maps_to_coin(self, api_currencies): + assert api_currencies[1] == "Coin" + + def test_karma_maps_to_karma(self, api_currencies): + assert api_currencies[2] == "Karma" + + def test_gems_maps_to_gem(self, api_currencies): + assert api_currencies[4] == "Gem" + + def test_laurels_maps_to_laurel(self, api_currencies): + assert api_currencies[3] == "Laurel" + + def test_spirit_shards_maps_correctly(self, api_currencies): + assert api_currencies[23] == "Spirit Shard" + + def test_badges_of_honor_maps_correctly(self, api_currencies): + assert api_currencies[15] == "Badge of Honor" + + def test_all_ids_have_api_data(self, api_currencies): + missing = set(WALLET_MAPPING.keys()) - set(api_currencies.keys()) + assert not missing, f"Could not fetch API data for currency IDs: {missing}" + + +@pytest.mark.gw2_api +class TestAchievementIdsExist: + """Validate that all ACHIEVEMENT_MAPPING IDs exist in the live GW2 API.""" + + @pytest.fixture(scope="class") + def api_achievements(self): + """Fetch achievement data for our mapped IDs from the GW2 API.""" + ids = ",".join(str(k) for k in sorted(ACHIEVEMENT_MAPPING.keys())) + return {a["id"]: a for a in _fetch_json(f"{GW2_API_BASE}/achievements?ids={ids}")} + + def test_all_achievement_ids_exist(self, api_achievements): + missing = set(ACHIEVEMENT_MAPPING.keys()) - set(api_achievements.keys()) + assert not missing, f"ACHIEVEMENT_MAPPING contains IDs not found in GW2 API: {missing}" + + def test_all_achievements_have_tiers(self, api_achievements): + """All WvW achievements should have progression tiers.""" + for ach_id, ach in api_achievements.items(): + assert "tiers" in ach, f"Achievement {ach_id} ({ach['name']}) has no tiers" + assert len(ach["tiers"]) > 0, f"Achievement {ach_id} ({ach['name']}) has empty tiers" + + def test_all_achievements_are_permanent(self, api_achievements): + """All WvW stat achievements should be permanent (not daily/weekly).""" + for ach_id, ach in api_achievements.items(): + flags = ach.get("flags", []) + assert "Permanent" in flags, f"Achievement {ach_id} ({ach['name']}) is not permanent" + + def test_players_killed_achievement_requirement(self, api_achievements): + ach = api_achievements[283] + assert "player" in ach.get("requirement", "").lower() + + def test_yaks_killed_achievement_requirement(self, api_achievements): + ach = api_achievements[288] + req = ach.get("requirement", "").lower() + assert "yak" in req or "caravan" in req or "dolyak" in req + + def test_camps_captured_achievement_requirement(self, api_achievements): + ach = api_achievements[291] + assert "camp" in ach.get("requirement", "").lower() + + +@pytest.mark.gw2_api +class TestWorldsEndpoint: + """Validate that the worlds endpoint is accessible and returns data.""" + + def test_can_fetch_world_details(self): + """Test that we can fetch details for a specific world.""" + worlds = _fetch_json(f"{GW2_API_BASE}/worlds?ids=1001") + assert len(worlds) == 1 + assert worlds[0]["id"] == 1001 + assert "name" in worlds[0] diff --git a/tests/integration/test_session_flow.py b/tests/integration/test_session_flow.py index caadb132..ef2ed813 100644 --- a/tests/integration/test_session_flow.py +++ b/tests/integration/test_session_flow.py @@ -168,12 +168,14 @@ async def test_session_start_end_lifecycle(db_session, log): assert session["end"]["gold"] == 120000 assert session["end"]["karma"] == 55000 - # NOTE: End character insertion fails due to UniqueConstraint("name") on - # gw2_session_chars — start chars already occupy those names. The code - # catches the IntegrityError silently (logged as error). This is a known - # schema limitation; start chars are still queryable. + # Migration 0011 dropped the unique constraint on name, so end chars + # can now be inserted alongside start chars for the same character name. end_chars = await chars_dal.get_all_end_characters(USER_ID) - assert len(end_chars) == 0 # blocked by unique constraint on name + assert len(end_chars) == 2 + end_char_names = {c["name"] for c in end_chars} + assert end_char_names == {"Warrior Prime", "Thief Shadow"} + end_warrior = next(c for c in end_chars if c["name"] == "Warrior Prime") + assert end_warrior["deaths"] == 45 async def test_end_session_without_start_is_noop(db_session, log): diff --git a/tests/unit/bot/constants/test_messages.py b/tests/unit/bot/constants/test_messages.py new file mode 100644 index 00000000..2cdc80e9 --- /dev/null +++ b/tests/unit/bot/constants/test_messages.py @@ -0,0 +1,604 @@ +"""Tests for bot message constants.""" + +from src.bot.constants import messages +from src.bot.constants.messages import ( + Admin, + Bot, + BotUtils, + CommandError, + Config, + CustomCommand, + DiceRolls, + GuildJoin, + GuildUpdate, + MemberJoin, + MemberRemove, + MemberUpdate, + Messages, + Misc, + Owner, + UserUpdate, +) + + +class TestBotClass: + """Test cases for Bot message class.""" + + def test_token_not_found(self): + assert Bot.TOKEN_NOT_FOUND == "BOT_TOKEN variable not found" + + def test_terminated(self): + assert Bot.TERMINATED == "Bot has been terminated." + + def test_stopped_ctrtc(self): + assert Bot.STOPPED_CTRTC == "Bot stopped with Ctrl+C" + + def test_fatal_error_main(self): + assert Bot.FATAL_ERROR_MAIN == "Fatal error in main()" + + def test_crashed(self): + assert Bot.CRASHED == "Bot crashed" + + def test_closing(self): + assert Bot.CLOSING == "Closing bot..." + + def test_login_failed(self): + assert Bot.LOGIN_FAILED == "Bot login failed" + + def test_init_prefix_failed(self): + assert Bot.INIT_PREFIX_FAILED == "Failed to get prefix from database, using default" + + def test_load_settings_failed(self): + assert Bot.LOAD_SETTINGS_FAILED == "Failed to load settings" + + def test_load_cogs_failed(self): + assert Bot.LOAD_COGS_FAILED == "Failed to load cogs" + + def test_loaded_all_cogs_success(self): + assert Bot.LOADED_ALL_COGS_SUCCESS == "Successfully loaded all cogs" + + def test_online(self): + result = Bot.online("TestBot#1234") + assert "TestBot#1234" in result + assert "ONLINE" in result + assert "CONNECTED TO DISCORD" in result + + def test_starting(self): + result = Bot.starting(5) + assert "5" in result + assert "secs" in result + + def test_disconnected(self): + result = Bot.disconnected("TestBot#1234") + assert "TestBot#1234" in result + assert "disconnected" in result + + +class TestAdminClass: + """Test cases for Admin message class.""" + + def test_announce_playing(self): + result = Admin.announce_playing("Test Game") + assert "Test Game" in result + assert "playing" in result + + def test_bg_task_warning(self): + result = Admin.bg_task_warning(300) + assert "300" in result + assert "Background task" in result + + +class TestConfigClass: + """Test cases for Config message class.""" + + def test_join(self): + assert "joins" in Config.JOIN + + def test_leave(self): + assert "leaves" in Config.LEAVE + + def test_server(self): + assert "updated" in Config.SERVER + + def test_member(self): + assert "profile" in Config.MEMBER + + def test_block_invis_members(self): + assert "invisible" in Config.BLOCK_INVIS_MEMBERS.lower() + + def test_bot_word_reactions(self): + assert "reactions" in Config.BOT_WORD_REACTIONS.lower() + + def test_pfilter_channels(self): + assert "profanity" in Config.PFILTER_CHANNELS.lower() + + def test_pfilter_function(self): + result = Config.pfilter("ON", "#general") + assert "ON" in result + assert "#general" in result + assert "Profanity Filter" in result + + def test_channel_id_instead_name(self): + assert isinstance(Config.CHANNEL_ID_INSTEAD_NAME, str) + + def test_not_activated_error(self): + assert "Profanity Filter" in Config.NOT_ACTIVATED_ERROR + + def test_missing_required_argument(self): + assert "Missing" in Config.MISSING_REQUIRED_ARGUMENT + + def test_channel_id_not_found(self): + assert "not found" in Config.CHANNEL_ID_NOT_FOUND + + def test_bot_missing_manage_messages(self): + assert "permission" in Config.BOT_MISSING_MANAGE_MESSAGES.lower() + + def test_no_channels_listed(self): + assert "No channels" in Config.NO_CHANNELS_LISTED + + +class TestCustomCommandClass: + """Test cases for CustomCommand message class.""" + + def test_already_standard_command(self): + assert "standard command" in CustomCommand.ALREADY_A_STANDARD_COMMAND + + def test_length_error(self): + assert "20 characters" in CustomCommand.LENGTH_ERROR + + def test_added(self): + assert "added" in CustomCommand.ADDED + + def test_edited(self): + assert "edited" in CustomCommand.EDITED + + def test_removed(self): + assert "removed" in CustomCommand.REMOVED + + def test_all_removed(self): + assert "removed" in CustomCommand.ALL_REMOVED + + def test_already_exists(self): + assert "exists" in CustomCommand.ALREADY_EXISTS + + def test_no_commands_found(self): + assert "no custom commands" in CustomCommand.NO_COMMANDS_FOUND.lower() + + def test_unable_remove(self): + assert "Unable" in CustomCommand.UNABLE_REMOVE + + def test_commands_server(self): + assert "Custom commands" in CustomCommand.COMMANDS_SERVER + + def test_get_configs_error(self): + assert "Error" in CustomCommand.GET_CONFIGS_ERROR + + +class TestCommandErrorClass: + """Test cases for CommandError message class.""" + + def test_missing_required_argument_help(self): + assert "Missing required argument" in CommandError.MISSING_REQUIRED_ARGUMENT_HELP + + def test_not_found(self): + assert CommandError.NOT_FOUND == "Command not found" + + def test_error(self): + assert CommandError.ERROR == "Command ERROR" + + def test_raised_exception(self): + assert "exception" in CommandError.RAISED_EXCEPTION.lower() + + def test_not_admin(self): + assert "Admin" in CommandError.NOT_ADMIN + + def test_owners_only(self): + assert "owners" in CommandError.OWNERS_ONLY.lower() + + def test_prefixes_choice(self): + assert "Prefixes" in CommandError.PREFIXES_CHOICE + + def test_more_info(self): + assert CommandError.MORE_INFO == "For more info" + + def test_unknown_option(self): + assert CommandError.UNKNOWN_OPTION == "Unknown option" + + def test_help_more_info(self): + assert "more info" in CommandError.HELP_MORE_INFO.lower() + + def test_no_option_found(self): + assert CommandError.NO_OPTION_FOUND == "No option found" + + def test_no_permission(self): + assert "permission" in CommandError.NO_PERMISSION.lower() + + def test_invalid_message(self): + assert CommandError.INVALID_MESSAGE == "Invalid message." + + def test_internal_error(self): + assert "internal error" in CommandError.INTERNAL_ERROR.lower() + + def test_dm_cannot_execute(self): + assert "DM" in CommandError.DM_CANNOT_EXECUTE + + def test_privilege_low(self): + assert "Privilege" in CommandError.PRIVILEGE_LOW + + def test_direct_messages_disabled(self): + assert "Direct messages" in CommandError.DIRECT_MESSAGES_DISABLED + + +class TestGuildJoinClass: + """Test cases for GuildJoin message class.""" + + def test_bot_message(self): + result = GuildJoin.bot_message("MyBot", "!", "GW2") + assert "MyBot" in result + assert "!" in result + assert "GW2" in result + assert "help" in result + + +class TestGuildUpdateClass: + """Test cases for GuildUpdate message class.""" + + def test_new_server_settings(self): + assert GuildUpdate.NEW_SERVER_SETTINGS == "New Server Settings" + + def test_new_server_icon(self): + assert GuildUpdate.NEW_SERVER_ICON == "New Server Icon" + + def test_new_server_name(self): + assert GuildUpdate.NEW_SERVER_NAME == "New Server Name" + + def test_previous_name(self): + assert GuildUpdate.PREVIOUS_NAME == "Previous Name" + + def test_previous_server_owner(self): + assert GuildUpdate.PREVIOUS_SERVER_OWNER == "Previous Server Owner" + + def test_new_server_owner(self): + assert GuildUpdate.NEW_SERVER_OWNER == "New Server Owner" + + +class TestMemberJoinClass: + """Test cases for MemberJoin message class.""" + + def test_joined_the_server(self): + assert MemberJoin.JOINED_THE_SERVER == "Joined the Server" + + +class TestMemberRemoveClass: + """Test cases for MemberRemove message class.""" + + def test_left_the_server(self): + assert MemberRemove.LEFT_THE_SERVER == "Left the Server" + + +class TestMemberUpdateClass: + """Test cases for MemberUpdate message class.""" + + def test_profile_changes(self): + assert MemberUpdate.PROFILE_CHANGES == "Profile Changes" + + def test_previous_nickname(self): + assert MemberUpdate.PREVIOUS_NICKNAME == "Previous Nickname" + + def test_new_nickname(self): + assert MemberUpdate.NEW_NICKNAME == "New Nickname" + + def test_previous_roles(self): + assert MemberUpdate.PREVIOUS_ROLES == "Previous Roles" + + def test_new_roles(self): + assert MemberUpdate.NEW_ROLES == "New Roles" + + +class TestMessagesClass: + """Test cases for Messages (on_message events) class.""" + + def test_bot_react_emojis(self): + assert ":rage:" in Messages.BOT_REACT_EMOJIS + + def test_owner_dm_bot_message(self): + assert "master" in Messages.OWNER_DM_BOT_MESSAGE.lower() + + def test_no_dm_messages(self): + assert "direct messages" in Messages.NO_DM_MESSAGES.lower() + + def test_dm_command_not_allowed(self): + assert "not allowed" in Messages.DM_COMMAND_NOT_ALLOWED.lower() + + def test_dm_commands_allow_list(self): + assert "allowed" in Messages.DM_COMMANDS_ALLOW_LIST.lower() + + def test_message_censured(self): + assert "censored" in Messages.MESSAGE_CENSURED.lower() + + def test_private_bot_message(self): + assert "Private Bot" in Messages.PRIVATE_BOT_MESSAGE + + def test_blocked_invis(self): + result = Messages.blocked_invis("Test Server") + assert "Test Server" in result + assert "Invisible" in result + + +class TestUserUpdateClass: + """Test cases for UserUpdate message class.""" + + def test_new_avatar(self): + assert UserUpdate.NEW_AVATAR == "New Avatar" + + def test_new_name(self): + assert UserUpdate.NEW_NAME == "New Name" + + def test_previous_discriminator(self): + assert UserUpdate.PREVIOUS_DISCRIMINATOR == "Previous Discriminator" + + def test_new_discriminator(self): + assert UserUpdate.NEW_DISCRIMINATOR == "New Discriminator" + + +class TestBotUtilsClass: + """Test cases for BotUtils message class.""" + + def test_loading_extensions(self): + assert "Loading" in BotUtils.LOADING_EXTENSIONS + + def test_loading_extension_failed(self): + assert "FAILED" in BotUtils.LOADING_EXTENSION_FAILED + + def test_disabled_dm(self): + assert "Direct messages" in BotUtils.DISABLED_DM + + def test_message_removed_for_privacy(self): + assert "privacy" in BotUtils.MESSAGE_REMOVED_FOR_PRIVACY.lower() + + def test_delete_message_no_permission(self): + assert "permission" in BotUtils.DELETE_MESSAGE_NO_PERMISSION.lower() + + +class TestDiceRollsClass: + """Test cases for DiceRolls message class.""" + + def test_size_not_valid(self): + assert "valid" in DiceRolls.SIZE_NOT_VALID.lower() + + def test_member_highest_roll_announce(self): + assert "highest roll" in DiceRolls.MEMBER_HIGHEST_ROLL_ANNOUNCE.lower() + + def test_server_highest_roll_announce(self): + assert "server highest" in DiceRolls.SERVER_HIGHEST_ROLL_ANNOUNCE.lower() + + def test_member_server_winner_announce(self): + assert "winner" in DiceRolls.MEMBER_SERVER_WINNER_ANNOUNCE.lower() + + def test_member_highest_roll(self): + assert "highest roll" in DiceRolls.MEMBER_HIGHEST_ROLL.lower() + + def test_member_has_highest_roll(self): + assert "highest roll" in DiceRolls.MEMBER_HAS_HIGHEST_ROLL.lower() + + def test_size_higher_one(self): + assert "higher than 1" in DiceRolls.SIZE_HIGHER_ONE + + def test_reset_all(self): + assert "Reset" in DiceRolls.RESET_ALL + + def test_deleted_all(self): + assert "deleted" in DiceRolls.DELETED_ALL.lower() + + def test_no_size_rolls(self): + result = DiceRolls.no_size_rolls(20) + assert "20" in result + assert "no dice rolls" in result.lower() + + +class TestMiscClass: + """Test cases for Misc message class.""" + + def test_pepe_download_error(self): + assert "pepe" in Misc.PEPE_DOWNLOAD_ERROR.lower() + + def test_invite_title(self): + assert Misc.INVITE_TITLE == "Invite Links" + + def test_unlimited_invites(self): + assert Misc.UNLIMITED_INVITES == "Unlimited Invites" + + def test_temporary_invites(self): + assert Misc.TEMPORARY_INVITES == "Temporary Invites" + + def test_revoked_invites(self): + assert Misc.REVOKED_INVITES == "Revoked Invites" + + def test_no_invites(self): + assert "No current invites" in Misc.NO_INVITES + + def test_do_not_disturb(self): + assert Misc.DO_NOT_DISTURB == "Do Not Disturb" + + def test_joined_discord_on(self): + assert Misc.JOINED_DISCORD_ON == "Joined Discord on" + + def test_joined_this_server_on(self): + assert Misc.JOINED_THIS_SERVER_ON == "Joined this server on" + + def test_list_command_categories(self): + assert "categories" in Misc.LIST_COMMAND_CATEGORIES.lower() + + def test_dev_info(self): + result = Misc.dev_info("https://github.com/test", "https://discordpy.readthedocs.io") + assert "https://github.com/test" in result + assert "https://discordpy.readthedocs.io" in result + assert "discord.py" in result + + +class TestOwnerClass: + """Test cases for Owner message class.""" + + def test_prefix_changed(self): + assert "prefix" in Owner.PREFIX_CHANGED.lower() + + def test_description_changed(self): + assert "description" in Owner.DESCRIPTION_CHANGED.lower() + + +class TestBackwardCompatibility: + """Test that module-level aliases match their class counterparts.""" + + def test_bot_constants(self): + assert messages.BOT_TOKEN_NOT_FOUND == Bot.TOKEN_NOT_FOUND + assert messages.BOT_TERMINATED == Bot.TERMINATED + assert messages.BOT_STOPPED_CTRTC == Bot.STOPPED_CTRTC + assert messages.BOT_FATAL_ERROR_MAIN == Bot.FATAL_ERROR_MAIN + assert messages.BOT_CRASHED == Bot.CRASHED + assert messages.BOT_CLOSING == Bot.CLOSING + assert messages.BOT_LOGIN_FAILED == Bot.LOGIN_FAILED + assert messages.BOT_INIT_PREFIX_FAILED == Bot.INIT_PREFIX_FAILED + assert messages.BOT_LOAD_SETTINGS_FAILED == Bot.LOAD_SETTINGS_FAILED + assert messages.BOT_LOAD_COGS_FAILED == Bot.LOAD_COGS_FAILED + assert messages.BOT_LOADED_ALL_COGS_SUCCESS == Bot.LOADED_ALL_COGS_SUCCESS + + def test_bot_functions(self): + assert messages.bot_online("X") == Bot.online("X") + assert messages.bot_starting(5) == Bot.starting(5) + assert messages.bot_disconnected("X") == Bot.disconnected("X") + + def test_admin_functions(self): + assert messages.bot_announce_playing("X") == Admin.announce_playing("X") + assert messages.bg_task_warning(60) == Admin.bg_task_warning(60) + + def test_config_constants(self): + assert messages.CONFIG_JOIN == Config.JOIN + assert messages.CONFIG_LEAVE == Config.LEAVE + assert messages.CONFIG_SERVER == Config.SERVER + assert messages.CONFIG_MEMBER == Config.MEMBER + assert messages.CONFIG_BLOCK_INVIS_MEMBERS == Config.BLOCK_INVIS_MEMBERS + assert messages.CONFIG_BOT_WORD_REACTIONS == Config.BOT_WORD_REACTIONS + assert messages.CONFIG_PFILTER_CHANNELS == Config.PFILTER_CHANNELS + assert messages.CONFIG_CHANNEL_ID_INSTEAD_NAME == Config.CHANNEL_ID_INSTEAD_NAME + assert messages.CONFIG_NOT_ACTIVATED_ERROR == Config.NOT_ACTIVATED_ERROR + assert messages.MISING_REUIRED_ARGUMENT == Config.MISSING_REQUIRED_ARGUMENT + assert messages.CHANNEL_ID_NOT_FOUND == Config.CHANNEL_ID_NOT_FOUND + assert messages.BOT_MISSING_MANAGE_MESSAGES_PERMISSION == Config.BOT_MISSING_MANAGE_MESSAGES + assert messages.NO_CHANNELS_LISTED == Config.NO_CHANNELS_LISTED + + def test_config_functions(self): + assert messages.config_pfilter("ON", "#ch") == Config.pfilter("ON", "#ch") + + def test_custom_command_constants(self): + assert messages.ALREADY_A_STANDARD_COMMAND == CustomCommand.ALREADY_A_STANDARD_COMMAND + assert messages.COMMAND_LENGHT_ERROR == CustomCommand.LENGTH_ERROR + assert messages.CUSTOM_COMMAND_ADDED == CustomCommand.ADDED + assert messages.CUSTOM_COMMAND_EDITED == CustomCommand.EDITED + assert messages.CUSTOM_COMMAND_REMOVED == CustomCommand.REMOVED + assert messages.CUSTOM_COMMAND_ALL_REMOVED == CustomCommand.ALL_REMOVED + assert messages.COMMAND_ALREADY_EXISTS == CustomCommand.ALREADY_EXISTS + assert messages.NO_CUSTOM_COMMANDS_FOUND == CustomCommand.NO_COMMANDS_FOUND + assert messages.CUSTOM_COMMAND_UNABLE_REMOVE == CustomCommand.UNABLE_REMOVE + assert messages.CUSTOM_COMMANDS_SERVER == CustomCommand.COMMANDS_SERVER + assert messages.GET_CONFIGS_ERROR == CustomCommand.GET_CONFIGS_ERROR + + def test_command_error_constants(self): + assert messages.MISSING_REQUIRED_ARGUMENT_HELP_MESSAGE == CommandError.MISSING_REQUIRED_ARGUMENT_HELP + assert messages.COMMAND_NOT_FOUND == CommandError.NOT_FOUND + assert messages.COMMAND_ERROR == CommandError.ERROR + assert messages.COMMAND_RAISED_EXCEPTION == CommandError.RAISED_EXCEPTION + assert messages.NOT_ADMIN_USE_COMMAND == CommandError.NOT_ADMIN + assert messages.BOT_OWNERS_ONLY_COMMAND == CommandError.OWNERS_ONLY + assert messages.PREFIXES_CHOICE == CommandError.PREFIXES_CHOICE + assert messages.MORE_INFO == CommandError.MORE_INFO + assert messages.UNKNOWN_OPTION == CommandError.UNKNOWN_OPTION + assert messages.HELP_COMMAND_MORE_INFO == CommandError.HELP_MORE_INFO + assert messages.NO_OPTION_FOUND == CommandError.NO_OPTION_FOUND + assert messages.NO_PERMISSION_EXECUTE_COMMAND == CommandError.NO_PERMISSION + assert messages.INVALID_MESSAGE == CommandError.INVALID_MESSAGE + assert messages.COMMAND_INTERNAL_ERROR == CommandError.INTERNAL_ERROR + assert messages.DM_CANNOT_EXECUTE_COMMAND == CommandError.DM_CANNOT_EXECUTE + assert messages.PRIVILEGE_LOW == CommandError.PRIVILEGE_LOW + assert messages.DIRECT_MESSAGES_DISABLED == CommandError.DIRECT_MESSAGES_DISABLED + + def test_guild_join_function(self): + assert messages.guild_join_bot_message("B", "!", "G") == GuildJoin.bot_message("B", "!", "G") + + def test_guild_update_constants(self): + assert messages.NEW_SERVER_SETTINGS == GuildUpdate.NEW_SERVER_SETTINGS + assert messages.NEW_SERVER_ICON == GuildUpdate.NEW_SERVER_ICON + assert messages.NEW_SERVER_NAME == GuildUpdate.NEW_SERVER_NAME + assert messages.PREVIOUS_NAME == GuildUpdate.PREVIOUS_NAME + assert messages.PREVIOUS_SERVER_OWNER == GuildUpdate.PREVIOUS_SERVER_OWNER + assert messages.NEW_SERVER_OWNER == GuildUpdate.NEW_SERVER_OWNER + + def test_member_join_constants(self): + assert messages.JOINED_THE_SERVER == MemberJoin.JOINED_THE_SERVER + + def test_member_remove_constants(self): + assert messages.LEFT_THE_SERVER == MemberRemove.LEFT_THE_SERVER + + def test_member_update_constants(self): + assert messages.PROFILE_CHANGES == MemberUpdate.PROFILE_CHANGES + assert messages.PREVIOUS_NICKNAME == MemberUpdate.PREVIOUS_NICKNAME + assert messages.NEW_NICKNAME == MemberUpdate.NEW_NICKNAME + assert messages.PREVIOUS_ROLES == MemberUpdate.PREVIOUS_ROLES + assert messages.NEW_ROLES == MemberUpdate.NEW_ROLES + + def test_messages_constants(self): + assert messages.BOT_REACT_EMOJIS == Messages.BOT_REACT_EMOJIS + assert messages.OWNER_DM_BOT_MESSAGE == Messages.OWNER_DM_BOT_MESSAGE + assert messages.NO_DM_MESSAGES == Messages.NO_DM_MESSAGES + assert messages.DM_COMMAND_NOT_ALLOWED == Messages.DM_COMMAND_NOT_ALLOWED + assert messages.DM_COMMANDS_ALLOW_LIST == Messages.DM_COMMANDS_ALLOW_LIST + assert messages.MESSAGE_CENSURED == Messages.MESSAGE_CENSURED + assert messages.PRIVATE_BOT_MESSAGE == Messages.PRIVATE_BOT_MESSAGE + + def test_messages_functions(self): + assert messages.blocked_invis_message("S") == Messages.blocked_invis("S") + + def test_user_update_constants(self): + assert messages.NEW_AVATAR == UserUpdate.NEW_AVATAR + assert messages.NEW_NAME == UserUpdate.NEW_NAME + assert messages.PREVIOUS_DISCRIMINATOR == UserUpdate.PREVIOUS_DISCRIMINATOR + assert messages.NEW_DISCRIMINATOR == UserUpdate.NEW_DISCRIMINATOR + + def test_bot_utils_constants(self): + assert messages.LOADING_EXTENSIONS == BotUtils.LOADING_EXTENSIONS + assert messages.LOADING_EXTENSION_FAILED == BotUtils.LOADING_EXTENSION_FAILED + assert messages.DISABLED_DM == BotUtils.DISABLED_DM + assert messages.MESSAGE_REMOVED_FOR_PRIVACY == BotUtils.MESSAGE_REMOVED_FOR_PRIVACY + assert messages.DELETE_MESSAGE_NO_PERMISSION == BotUtils.DELETE_MESSAGE_NO_PERMISSION + + def test_dice_rolls_constants(self): + assert messages.DICE_SIZE_NOT_VALID == DiceRolls.SIZE_NOT_VALID + assert messages.MEMBER_HIGHEST_ROLL_ANOUNCE == DiceRolls.MEMBER_HIGHEST_ROLL_ANNOUNCE + assert messages.SERVER_HIGHEST_ROLL_ANOUNCE == DiceRolls.SERVER_HIGHEST_ROLL_ANNOUNCE + assert messages.MEMBER_SERVER_WINNER_ANOUNCE == DiceRolls.MEMBER_SERVER_WINNER_ANNOUNCE + assert messages.MEMBER_HIGHEST_ROLL == DiceRolls.MEMBER_HIGHEST_ROLL + assert messages.MEMBER_HAS_HIGHEST_ROLL == DiceRolls.MEMBER_HAS_HIGHEST_ROLL + assert messages.DICE_SIZE_HIGHER_ONE == DiceRolls.SIZE_HIGHER_ONE + assert messages.RESET_ALL_ROLLS == DiceRolls.RESET_ALL + assert messages.DELETED_ALL_ROLLS == DiceRolls.DELETED_ALL + + def test_dice_rolls_functions(self): + assert messages.no_dice_size_rolls(6) == DiceRolls.no_size_rolls(6) + + def test_misc_constants(self): + assert messages.PEPE_DOWNLOAD_ERROR == Misc.PEPE_DOWNLOAD_ERROR + assert messages.INVITE_TITLE == Misc.INVITE_TITLE + assert messages.UNLIMITED_INVITES == Misc.UNLIMITED_INVITES + assert messages.TEMPORARY_INVITES == Misc.TEMPORARY_INVITES + assert messages.REVOKED_INVITES == Misc.REVOKED_INVITES + assert messages.NO_INVITES == Misc.NO_INVITES + assert messages.DO_NOT_DISTURB == Misc.DO_NOT_DISTURB + assert messages.JOINED_DISCORD_ON == Misc.JOINED_DISCORD_ON + assert messages.JOINED_THIS_SERVER_ON == Misc.JOINED_THIS_SERVER_ON + assert messages.LIST_COMMAND_CATEGORIES == Misc.LIST_COMMAND_CATEGORIES + + def test_misc_functions(self): + assert messages.dev_info_msg("u1", "u2") == Misc.dev_info("u1", "u2") + + def test_owner_constants(self): + assert messages.BOT_PREFIX_CHANGED == Owner.PREFIX_CHANGED + assert messages.BOT_DESCRIPTION_CHANGED == Owner.DESCRIPTION_CHANGED diff --git a/tests/unit/gw2/cogs/test_sessions.py b/tests/unit/gw2/cogs/test_sessions.py index 85647280..1d6df9b8 100644 --- a/tests/unit/gw2/cogs/test_sessions.py +++ b/tests/unit/gw2/cogs/test_sessions.py @@ -2,7 +2,15 @@ import discord import pytest -from src.gw2.cogs.sessions import GW2Session, session, setup +from src.gw2.cogs.sessions import ( + GW2Session, + _add_deaths_field, + _add_gold_field, + _add_wallet_currency_fields, + _add_wvw_stats, + session, + setup, +) from unittest.mock import AsyncMock, MagicMock, patch @@ -35,6 +43,44 @@ def test_gw2_session_inheritance(self, gw2_session_cog): assert isinstance(gw2_session_cog, GuildWars2) +def _make_session_data(start_overrides=None, end_overrides=None): + """Helper to create session data with defaults.""" + base_stats = { + "date": "2024-01-15 10:00:00", + "age": 5000000, + "gold": 100000, + "karma": 50000, + "laurels": 100, + "wvw_rank": 500, + "yaks": 10, + "yaks_scorted": 5, + "players": 20, + "keeps": 2, + "towers": 5, + "camps": 10, + "castles": 1, + "wvw_tickets": 100, + "proof_heroics": 50, + "badges_honor": 200, + "guild_commendations": 30, + "spirit_shards": 100, + "transmutation_charges": 50, + "volatile_magic": 1000, + "unbound_magic": 500, + "gems": 0, + } + end_stats = { + "date": "2024-01-15 12:30:00", + "age": 5009000, + **{k: v for k, v in base_stats.items() if k not in ("date", "age")}, + } + if start_overrides: + base_stats.update(start_overrides) + if end_overrides: + end_stats.update(end_overrides) + return [{"acc_name": "TestUser.1234", "start": base_stats, "end": end_stats}] + + class TestSessionCommand: """Test cases for the session command.""" @@ -76,48 +122,28 @@ def sample_api_key_data(self): @pytest.fixture def sample_session_data(self): - """Create sample session data.""" - return [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 150000, - "karma": 55000, - "laurels": 105, - "wvw_rank": 502, - "yaks": 15, - "yaks_scorted": 8, - "players": 35, - "keeps": 4, - "towers": 8, - "camps": 15, - "castles": 2, - "wvw_tickets": 120, - "proof_heroics": 60, - "badges_honor": 230, - "guild_commendations": 35, - }, - } - ] + """Create sample session data with all stats changed.""" + return _make_session_data( + end_overrides={ + "gold": 150000, + "karma": 55000, + "laurels": 105, + "wvw_rank": 502, + "yaks": 15, + "yaks_scorted": 8, + "players": 35, + "keeps": 4, + "towers": 8, + "camps": 15, + "castles": 2, + "wvw_tickets": 120, + "proof_heroics": 60, + "badges_honor": 230, + "guild_commendations": 35, + "spirit_shards": 110, + "volatile_magic": 1200, + }, + ) @pytest.fixture def sample_time_passed(self): @@ -132,170 +158,136 @@ def sample_time_passed(self): time_obj.timedelta = "2:30:00" return time_obj + def _run_session(self, mock_ctx, sample_api_key_data, session_data, sample_time_passed, extra_patches=None): + """Helper that sets up all patches for a successful session command call. + Returns a context manager dict with mock references.""" + import contextlib + + class SessionRunner: + def __init__(self): + self.mock_send = None + self.mock_chars_dal = None + self.mock_end_session = None + + @contextlib.asynccontextmanager + async def run(self_runner): + with ( + patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal, + patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs, + patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal, + patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short", side_effect=lambda x: x), + patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed", return_value=sample_time_passed), + patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal_class, + patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send, + patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"), + ): + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) + mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=session_data) + mock_chars_dal_class.return_value.get_all_start_characters = AsyncMock(return_value=None) + mock_chars_dal_class.return_value.get_all_end_characters = AsyncMock(return_value=None) + + self_runner.mock_send = mock_send + self_runner.mock_chars_dal = mock_chars_dal_class.return_value + + yield self_runner + + return SessionRunner() + + # === Error path tests === + @pytest.mark.asyncio async def test_session_no_api_key(self, mock_ctx): """Test session command when user has no API key.""" with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=None) - + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=None) with patch("src.gw2.cogs.sessions.bot_utils.send_error_msg") as mock_error: await session(mock_ctx) - mock_error.assert_called_once() - error_msg = mock_error.call_args[0][1] - assert "You dont have an API key registered" in error_msg + assert "You dont have an API key registered" in mock_error.call_args[0][1] @pytest.mark.asyncio async def test_session_not_active_in_config(self, mock_ctx, sample_api_key_data): """Test session command when session is not active in server config.""" with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": False}]) - + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": False}]) with patch("src.gw2.cogs.sessions.bot_utils.send_warning_msg") as mock_warning: await session(mock_ctx) - mock_warning.assert_called_once() @pytest.mark.asyncio async def test_session_not_active_empty_config(self, mock_ctx, sample_api_key_data): """Test session command when server config is empty.""" with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[]) - + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[]) with patch("src.gw2.cogs.sessions.bot_utils.send_warning_msg") as mock_warning: await session(mock_ctx) - mock_warning.assert_called_once() @pytest.mark.asyncio async def test_session_missing_all_permissions(self, mock_ctx): """Test session command when all required permissions are missing.""" - api_key_no_perms = [ - { - "key": "test-api-key-12345", - "server": "Anvil Rock", - "permissions": "guilds,pvp", - } - ] - + api_key_no_perms = [{"key": "test-api-key", "server": "Anvil Rock", "permissions": "guilds,pvp"}] with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=api_key_no_perms) - + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=api_key_no_perms) with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) with patch("src.gw2.cogs.sessions.bot_utils.send_error_msg") as mock_error: await session(mock_ctx) - mock_error.assert_called_once() - error_msg = mock_error.call_args[0][1] - assert "permissions" in error_msg.lower() + assert "permissions" in mock_error.call_args[0][1].lower() @pytest.mark.asyncio async def test_session_has_some_permissions_not_all_missing(self, mock_ctx): """Test session command when some but not all permissions are present (should pass).""" - api_key_some_perms = [ - { - "key": "test-api-key-12345", - "server": "Anvil Rock", - "permissions": "account,guilds", # Only account present, missing wallet/progression/characters - } - ] - + api_key_some_perms = [{"key": "test-api-key", "server": "Anvil Rock", "permissions": "account,guilds"}] with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=api_key_some_perms) - + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=api_key_some_perms) with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=None) - + mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=None) with patch("src.gw2.cogs.sessions.bot_utils.send_error_msg") as mock_error: await session(mock_ctx) - - # Should not error on permissions since at least one is present - # Should error on no session found instead mock_error.assert_called_once() @pytest.mark.asyncio async def test_session_no_session_found(self, mock_ctx, sample_api_key_data): """Test session command when no session records are found.""" with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=None) - + mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=None) with patch("src.gw2.cogs.sessions.bot_utils.send_error_msg") as mock_error: await session(mock_ctx) - mock_error.assert_called_once() - # Second arg is the message, third is True for dm assert mock_error.call_args[0][2] is True @pytest.mark.asyncio async def test_session_end_date_is_none(self, mock_ctx, sample_api_key_data): """Test session command when session end date is None.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": {"date": "2024-01-15 10:00:00"}, - "end": {"date": None}, - } - ] - + session_data = [{"acc_name": "TestUser.1234", "start": {"date": "2024-01-15 10:00:00"}, "end": {"date": None}}] with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - + mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=session_data) with patch("src.gw2.cogs.sessions.bot_utils.send_error_msg") as mock_error: await session(mock_ctx) - mock_error.assert_called_once() assert mock_error.call_args[0][2] is True @pytest.mark.asyncio async def test_session_time_passed_less_than_one_minute(self, mock_ctx, sample_api_key_data): """Test session command when time passed is less than 1 minute.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": {"date": "2024-01-15 10:00:00"}, - "end": {"date": "2024-01-15 10:00:30"}, - } - ] - + session_data = _make_session_data(end_overrides={"date": "2024-01-15 10:00:30"}) from src.gw2.tools.gw2_utils import TimeObject time_obj = TimeObject() @@ -304,1510 +296,436 @@ async def test_session_time_passed_less_than_one_minute(self, mock_ctx, sample_a time_obj.seconds = 30 with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - + mock_dal.return_value.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - + mock_configs.return_value.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = time_obj - + mock_sessions_dal.return_value.get_user_last_session = AsyncMock(return_value=session_data) + with patch( + "src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short", side_effect=lambda x: x + ): + with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed", return_value=time_obj): with patch("src.gw2.cogs.sessions.gw2_utils.send_msg") as mock_send_msg: await session(mock_ctx) - mock_send_msg.assert_called_once() msg = mock_send_msg.call_args[0][1] assert "still updating" in msg.lower() or "Bot still updating" in msg - @pytest.mark.asyncio - async def test_session_gold_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): - """Test session command when gold is gained (positive).""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 150000, # Gained 50000 - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x + # === Playtime tests === - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.gw2_utils.format_gold") as mock_format: - mock_format.return_value = "5g 00s 00c" - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) + @pytest.mark.asyncio + async def test_session_play_time_from_api_age(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test that play time uses API age difference when available.""" + session_data = _make_session_data( + start_overrides={"age": 5000000}, + end_overrides={"age": 5009000}, + ) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + play_time_field = next((f for f in embed.fields if f.name == "Play time"), None) + assert play_time_field is not None + assert "2h 30m" in play_time_field.value - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", - side_effect=lambda x: f"`{x}`", - ): - await session(mock_ctx) + @pytest.mark.asyncio + async def test_session_play_time_fallback_when_no_age(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test that play time falls back to timedelta when age is 0/missing.""" + session_data = _make_session_data( + start_overrides={"age": 0}, + end_overrides={"age": 0}, + ) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + play_time_field = next((f for f in embed.fields if f.name == "Play time"), None) + assert play_time_field is not None + assert "2:30:00" in play_time_field.value + + # === Gold tests === - mock_send.assert_called_once() - embed = mock_send.call_args[0][1] - gold_field = next( - (f for f in embed.fields if f.name == "Gained gold"), None - ) - assert gold_field is not None - assert "+" in gold_field.value + @pytest.mark.asyncio + async def test_session_gold_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command when gold is gained (positive).""" + session_data = _make_session_data(end_overrides={"gold": 150000}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + with patch("src.gw2.cogs.sessions.gw2_utils.format_gold", return_value="5 Gold 00 Silver 00 Copper"): + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + gold_field = next((f for f in embed.fields if f.name == "Gained Gold"), None) + assert gold_field is not None + assert "+" in gold_field.value @pytest.mark.asyncio async def test_session_gold_lost(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when gold is lost (negative).""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 150000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, # Lost 50000 - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.gw2_utils.format_gold") as mock_format: - mock_format.return_value = "5g 00s 00c" - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", - side_effect=lambda x: f"`{x}`", - ): - await session(mock_ctx) - - mock_send.assert_called_once() - embed = mock_send.call_args[0][1] - gold_field = next((f for f in embed.fields if f.name == "Lost gold"), None) - assert gold_field is not None + session_data = _make_session_data(start_overrides={"gold": 150000}, end_overrides={"gold": 100000}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + with patch("src.gw2.cogs.sessions.gw2_utils.format_gold", return_value="5 Gold 00 Silver 00 Copper"): + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + gold_field = next((f for f in embed.fields if f.name == "Lost Gold"), None) + assert gold_field is not None @pytest.mark.asyncio async def test_session_gold_lost_with_leading_dash(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when gold is lost and formatted gold starts with dash.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 150000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.gw2_utils.format_gold") as mock_format: - # Formatted gold already starts with dash - mock_format.return_value = "-5g 00s 00c" - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", - side_effect=lambda x: f"`{x}`", - ): - await session(mock_ctx) + session_data = _make_session_data(start_overrides={"gold": 150000}, end_overrides={"gold": 100000}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + with patch("src.gw2.cogs.sessions.gw2_utils.format_gold", return_value="-5 Gold 00 Silver 00 Copper"): + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + gold_field = next((f for f in embed.fields if f.name == "Lost Gold"), None) + assert gold_field is not None + assert "--" not in gold_field.value - mock_send.assert_called_once() - embed = mock_send.call_args[0][1] - gold_field = next((f for f in embed.fields if f.name == "Lost gold"), None) - assert gold_field is not None - # Should not have double dash - assert "--" not in gold_field.value + # === Deaths tests === @pytest.mark.asyncio async def test_session_characters_with_deaths(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command with character deaths.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - + session_data = _make_session_data() chars_start = [ {"name": "TestChar", "profession": "Warrior", "deaths": 10}, {"name": "TestChar2", "profession": "Ranger", "deaths": 5}, ] chars_end = [ {"name": "TestChar", "profession": "Warrior", "deaths": 15}, - {"name": "TestChar2", "profession": "Ranger", "deaths": 5}, # No change + {"name": "TestChar2", "profession": "Ranger", "deaths": 5}, ] + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + r.mock_chars_dal.get_all_start_characters = AsyncMock(return_value=chars_start) + r.mock_chars_dal.get_all_end_characters = AsyncMock(return_value=chars_end) + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + deaths_field = next((f for f in embed.fields if f.name == "Times you died"), None) + assert deaths_field is not None + assert "Warrior" in deaths_field.value + assert "TestChar" in deaths_field.value + assert "Total:5" in deaths_field.value - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=chars_start) - mock_chars_instance.get_all_end_characters = AsyncMock(return_value=chars_end) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - mock_send.assert_called_once() - embed = mock_send.call_args[0][1] - deaths_field = next( - (f for f in embed.fields if f.name == "Times you died"), None - ) - assert deaths_field is not None - assert "Warrior" in deaths_field.value - assert "TestChar" in deaths_field.value - assert "Total:5" in deaths_field.value + @pytest.mark.asyncio + async def test_session_no_deaths_when_unchanged(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command shows no deaths field when deaths unchanged.""" + session_data = _make_session_data() + chars_start = [{"name": "TestChar", "profession": "Warrior", "deaths": 10}] + chars_end = [{"name": "TestChar", "profession": "Warrior", "deaths": 10}] + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + r.mock_chars_dal.get_all_start_characters = AsyncMock(return_value=chars_start) + r.mock_chars_dal.get_all_end_characters = AsyncMock(return_value=chars_end) + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + deaths_field = next((f for f in embed.fields if f.name == "Times you died"), None) + assert deaths_field is None @pytest.mark.asyncio - async def test_session_karma_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): - """Test session command when karma is gained.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 55000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } + async def test_session_multiple_character_deaths(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command with multiple characters dying.""" + session_data = _make_session_data() + chars_start = [ + {"name": "Char1", "profession": "Warrior", "deaths": 10}, + {"name": "Char2", "profession": "Mesmer", "deaths": 5}, ] + chars_end = [ + {"name": "Char1", "profession": "Warrior", "deaths": 13}, + {"name": "Char2", "profession": "Mesmer", "deaths": 8}, + ] + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + r.mock_chars_dal.get_all_start_characters = AsyncMock(return_value=chars_start) + r.mock_chars_dal.get_all_end_characters = AsyncMock(return_value=chars_end) + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + deaths_field = next((f for f in embed.fields if f.name == "Times you died"), None) + assert deaths_field is not None + assert "Total:6" in deaths_field.value + assert "Warrior" in deaths_field.value + assert "Mesmer" in deaths_field.value + + # === WvW stats tests === - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) + @pytest.mark.asyncio + async def test_session_wvw_rank_change(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command when WvW rank changes.""" + session_data = _make_session_data(end_overrides={"wvw_rank": 502}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + wvw_field = next((f for f in embed.fields if f.name == "Gained WvW ranks"), None) + assert wvw_field is not None + assert "2" in wvw_field.value - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) + @pytest.mark.asyncio + async def test_session_all_wvw_stats(self, mock_ctx, sample_api_key_data, sample_session_data, sample_time_passed): + """Test session command with all WvW stats changed.""" + runner = self._run_session(mock_ctx, sample_api_key_data, sample_session_data, sample_time_passed) + async with runner.run() as r: + with patch("src.gw2.cogs.sessions.gw2_utils.format_gold", return_value="5 Gold"): + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field_names = [f.name for f in embed.fields] + assert "Yaks killed" in field_names + assert "Yaks escorted" in field_names + assert "Players killed" in field_names + assert "Keeps captured" in field_names + assert "Towers captured" in field_names + assert "Camps captured" in field_names + assert "SMC captured" in field_names + + # === Wallet currency tests === - embed = mock_send.call_args[0][1] - karma_field = next((f for f in embed.fields if f.name == "Gained karma"), None) - assert karma_field is not None + @pytest.mark.asyncio + async def test_session_karma_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command when karma is gained.""" + session_data = _make_session_data(end_overrides={"karma": 55000}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + karma_field = next((f for f in embed.fields if f.name == "Gained Karma"), None) + assert karma_field is not None @pytest.mark.asyncio async def test_session_karma_lost(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when karma is lost.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 55000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - embed = mock_send.call_args[0][1] - karma_field = next((f for f in embed.fields if f.name == "Lost karma"), None) - assert karma_field is not None + session_data = _make_session_data(start_overrides={"karma": 55000}, end_overrides={"karma": 50000}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + karma_field = next((f for f in embed.fields if f.name == "Lost Karma"), None) + assert karma_field is not None @pytest.mark.asyncio async def test_session_laurels_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when laurels are gained.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 105, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - embed = mock_send.call_args[0][1] - laurels_field = next( - (f for f in embed.fields if f.name == "Gained laurels"), None - ) - assert laurels_field is not None - - @pytest.mark.asyncio - async def test_session_wvw_rank_change(self, mock_ctx, sample_api_key_data, sample_time_passed): - """Test session command when WvW rank changes.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 502, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - embed = mock_send.call_args[0][1] - wvw_rank_field = next( - (f for f in embed.fields if f.name == "Gained wvw ranks"), None - ) - assert wvw_rank_field is not None - assert "2" in wvw_rank_field.value + session_data = _make_session_data(end_overrides={"laurels": 105}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Gained Laurels"), None) + assert field is not None @pytest.mark.asyncio - async def test_session_all_wvw_stats(self, mock_ctx, sample_api_key_data, sample_session_data, sample_time_passed): - """Test session command with all WvW stats changed (yaks, players, keeps, towers, camps, castles).""" - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=sample_session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.gw2_utils.format_gold") as mock_format: - mock_format.return_value = "5g 00s 00c" - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", - side_effect=lambda x: f"`{x}`", - ): - await session(mock_ctx) - - embed = mock_send.call_args[0][1] - field_names = [f.name for f in embed.fields] - assert "Yaks killed" in field_names - assert "Yaks scorted" in field_names - assert "Players killed" in field_names - assert "Keeps captured" in field_names - assert "Towers captured" in field_names - assert "Camps captured" in field_names - assert "SMC captured" in field_names + async def test_session_laurels_lost(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command when laurels are lost.""" + session_data = _make_session_data(start_overrides={"laurels": 105}, end_overrides={"laurels": 100}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Lost Laurels"), None) + assert field is not None @pytest.mark.asyncio async def test_session_wvw_tickets_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when WvW tickets are gained.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 120, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - embed = mock_send.call_args[0][1] - tickets_field = next( - (f for f in embed.fields if f.name == "Gained wvw tickets"), None - ) - assert tickets_field is not None + session_data = _make_session_data(end_overrides={"wvw_tickets": 120}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Gained WvW Skirmish Tickets"), None) + assert field is not None @pytest.mark.asyncio async def test_session_wvw_tickets_lost(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when WvW tickets are lost.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 120, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - embed = mock_send.call_args[0][1] - tickets_field = next( - (f for f in embed.fields if f.name == "Lost wvw tickets"), None - ) - assert tickets_field is not None + session_data = _make_session_data(start_overrides={"wvw_tickets": 120}, end_overrides={"wvw_tickets": 100}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Lost WvW Skirmish Tickets"), None) + assert field is not None @pytest.mark.asyncio async def test_session_proof_heroics_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when proof of heroics are gained.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 60, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - embed = mock_send.call_args[0][1] - heroics_field = next( - (f for f in embed.fields if f.name == "Gained proof heroics"), None - ) - assert heroics_field is not None + session_data = _make_session_data(end_overrides={"proof_heroics": 60}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Gained Proof of Heroics"), None) + assert field is not None @pytest.mark.asyncio async def test_session_badges_honor_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when badges of honor are gained.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 230, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - embed = mock_send.call_args[0][1] - badges_field = next( - (f for f in embed.fields if f.name == "Gained badges of honor"), None - ) - assert badges_field is not None + session_data = _make_session_data(end_overrides={"badges_honor": 230}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Gained Badges of Honor"), None) + assert field is not None @pytest.mark.asyncio async def test_session_guild_commendations_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when guild commendations are gained.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 35, - }, - } - ] + session_data = _make_session_data(end_overrides={"guild_commendations": 35}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Gained Guild Commendations"), None) + assert field is not None - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) + # === New currency tests === - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) + @pytest.mark.asyncio + async def test_session_spirit_shards_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command when spirit shards are gained.""" + session_data = _make_session_data(end_overrides={"spirit_shards": 110}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Gained Spirit Shards"), None) + assert field is not None + assert "+10" in field.value - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x + @pytest.mark.asyncio + async def test_session_volatile_magic_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command when volatile magic is gained.""" + session_data = _make_session_data(end_overrides={"volatile_magic": 1200}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Gained Volatile Magic"), None) + assert field is not None + assert "+200" in field.value - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed + @pytest.mark.asyncio + async def test_session_unbound_magic_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command when unbound magic is gained.""" + session_data = _make_session_data(end_overrides={"unbound_magic": 700}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Gained Unbound Magic"), None) + assert field is not None + assert "+200" in field.value - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) + @pytest.mark.asyncio + async def test_session_transmutation_charges_gained(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command when transmutation charges are gained.""" + session_data = _make_session_data(end_overrides={"transmutation_charges": 55}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Gained Transmutation Charges"), None) + assert field is not None + assert "+5" in field.value - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) + @pytest.mark.asyncio + async def test_session_currency_lost(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test session command when a currency is lost (negative diff).""" + session_data = _make_session_data(start_overrides={"spirit_shards": 110}, end_overrides={"spirit_shards": 100}) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field = next((f for f in embed.fields if f.name == "Lost Spirit Shards"), None) + assert field is not None + assert "-10" in field.value - embed = mock_send.call_args[0][1] - commendations_field = next( - (f for f in embed.fields if f.name == "Gained guild commendations"), None - ) - assert commendations_field is not None + @pytest.mark.asyncio + async def test_session_no_currency_field_when_unchanged(self, mock_ctx, sample_api_key_data, sample_time_passed): + """Test that no currency field is added when values are unchanged.""" + session_data = _make_session_data() # All values same between start/end + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + embed = r.mock_send.call_args[0][1] + field_names = [f.name for f in embed.fields] + # Only basic fields should be present + assert "Account Name" in field_names + assert "Server" in field_names + assert "Play time" in field_names + # No gained/lost fields + gained_lost = [n for n in field_names if "Gained" in n or "Lost" in n] + assert len(gained_lost) == 0 + + # === Still playing / DM tests === @pytest.mark.asyncio async def test_session_still_playing_gw2(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when user is still playing GW2.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - # Set up activity + session_data = _make_session_data() mock_ctx.message.author.activity = MagicMock() mock_ctx.message.author.activity.name = "Guild Wars 2" - mock_ctx.channel = MagicMock(spec=discord.TextChannel) # Not a DMChannel - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) + mock_ctx.channel = MagicMock(spec=discord.TextChannel) - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch( - "src.gw2.cogs.sessions.gw2_utils.end_session", new_callable=AsyncMock - ) as mock_end_session: - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", - side_effect=lambda x: f"`{x}`", - ): - await session(mock_ctx) - - mock_end_session.assert_called_once() - mock_ctx.send.assert_called_once() - still_playing_msg = mock_ctx.send.call_args[0][0] - assert "playing Guild Wars 2" in still_playing_msg + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + with patch("src.gw2.cogs.sessions.gw2_utils.end_session", new_callable=AsyncMock) as mock_end_session: + await session(mock_ctx) + mock_end_session.assert_called_once() + mock_ctx.send.assert_called_once() + assert "playing Guild Wars 2" in mock_ctx.send.call_args[0][0] @pytest.mark.asyncio async def test_session_not_playing_gw2_no_activity(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command when user has no activity (not playing).""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - + session_data = _make_session_data() mock_ctx.message.author.activity = None - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch( - "src.gw2.cogs.sessions.gw2_utils.end_session", new_callable=AsyncMock - ) as mock_end_session: - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", - side_effect=lambda x: f"`{x}`", - ): - await session(mock_ctx) - - # end_session should NOT be called - mock_end_session.assert_not_called() - mock_ctx.send.assert_not_called() - mock_send.assert_called_once() + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + with patch("src.gw2.cogs.sessions.gw2_utils.end_session", new_callable=AsyncMock) as mock_end_session: + await session(mock_ctx) + mock_end_session.assert_not_called() + mock_ctx.send.assert_not_called() + r.mock_send.assert_called_once() @pytest.mark.asyncio async def test_session_dm_channel_no_still_playing(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command in DM channel does not trigger still playing message.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - # Set channel to DMChannel + session_data = _make_session_data() mock_ctx.channel = MagicMock(spec=discord.DMChannel) mock_ctx.message.author.activity = MagicMock() mock_ctx.message.author.activity.name = "Guild Wars 2" - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch( - "src.gw2.cogs.sessions.gw2_utils.end_session", new_callable=AsyncMock - ) as mock_end_session: - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", - side_effect=lambda x: f"`{x}`", - ): - await session(mock_ctx) + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + with patch("src.gw2.cogs.sessions.gw2_utils.end_session", new_callable=AsyncMock) as mock_end_session: + await session(mock_ctx) + mock_end_session.assert_not_called() + r.mock_send.assert_called_once() - # In DM channel, end_session should NOT be called - mock_end_session.assert_not_called() - mock_send.assert_called_once() + # === Basic embed tests === @pytest.mark.asyncio async def test_session_successful_embed_basic_fields(self, mock_ctx, sample_api_key_data, sample_time_passed): """Test session command sends embed with basic fields.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - mock_send.assert_called_once() - embed = mock_send.call_args[0][1] - field_names = [f.name for f in embed.fields] - assert "Account Name" in field_names - assert "Server" in field_names - assert "Total played time" in field_names + session_data = _make_session_data() + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, sample_time_passed) + async with runner.run() as r: + await session(mock_ctx) + r.mock_send.assert_called_once() + embed = r.mock_send.call_args[0][1] + field_names = [f.name for f in embed.fields] + assert "Account Name" in field_names + assert "Server" in field_names + assert "Play time" in field_names @pytest.mark.asyncio async def test_session_time_passed_exactly_one_minute(self, mock_ctx, sample_api_key_data): """Test session command when time passed is exactly 1 minute (should proceed).""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 10:01:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - + session_data = _make_session_data(end_overrides={"date": "2024-01-15 10:01:00"}) from src.gw2.tools.gw2_utils import TimeObject time_obj = TimeObject() @@ -1816,115 +734,160 @@ async def test_session_time_passed_exactly_one_minute(self, mock_ctx, sample_api time_obj.seconds = 0 time_obj.timedelta = "0:01:00" - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = time_obj - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - # Should proceed normally since minutes >= 1 - mock_send.assert_called_once() - - @pytest.mark.asyncio - async def test_session_laurels_lost(self, mock_ctx, sample_api_key_data, sample_time_passed): - """Test session command when laurels are lost.""" - session_data = [ - { - "acc_name": "TestUser.1234", - "start": { - "date": "2024-01-15 10:00:00", - "gold": 100000, - "karma": 50000, - "laurels": 105, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - "end": { - "date": "2024-01-15 12:30:00", - "gold": 100000, - "karma": 50000, - "laurels": 100, - "wvw_rank": 500, - "yaks": 10, - "yaks_scorted": 5, - "players": 20, - "keeps": 2, - "towers": 5, - "camps": 10, - "castles": 1, - "wvw_tickets": 100, - "proof_heroics": 50, - "badges_honor": 200, - "guild_commendations": 30, - }, - } - ] - - with patch("src.gw2.cogs.sessions.Gw2KeyDal") as mock_dal: - mock_instance = mock_dal.return_value - mock_instance.get_api_key_by_user = AsyncMock(return_value=sample_api_key_data) - - with patch("src.gw2.cogs.sessions.Gw2ConfigsDal") as mock_configs: - mock_configs_instance = mock_configs.return_value - mock_configs_instance.get_gw2_server_configs = AsyncMock(return_value=[{"session": True}]) - - with patch("src.gw2.cogs.sessions.Gw2SessionsDal") as mock_sessions_dal: - mock_sessions_instance = mock_sessions_dal.return_value - mock_sessions_instance.get_user_last_session = AsyncMock(return_value=session_data) - - with patch("src.gw2.cogs.sessions.bot_utils.convert_str_to_datetime_short") as mock_convert: - mock_convert.side_effect = lambda x: x - - with patch("src.gw2.cogs.sessions.gw2_utils.get_time_passed") as mock_time: - mock_time.return_value = sample_time_passed - - with patch("src.gw2.cogs.sessions.Gw2SessionCharsDal") as mock_chars_dal: - mock_chars_instance = mock_chars_dal.return_value - mock_chars_instance.get_all_start_characters = AsyncMock(return_value=None) - - with patch("src.gw2.cogs.sessions.bot_utils.send_embed") as mock_send: - with patch( - "src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`" - ): - await session(mock_ctx) - - embed = mock_send.call_args[0][1] - laurels_field = next( - (f for f in embed.fields if f.name == "Lost laurels"), None - ) - assert laurels_field is not None + runner = self._run_session(mock_ctx, sample_api_key_data, session_data, time_obj) + async with runner.run() as r: + await session(mock_ctx) + r.mock_send.assert_called_once() + + +class TestAddGoldField: + """Test the _add_gold_field helper function.""" + + def test_gold_gained(self): + embed = discord.Embed() + with patch("src.gw2.cogs.sessions.gw2_utils.format_gold", return_value="5 Gold"): + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_gold_field(embed, {"gold": 100}, {"gold": 200}) + assert len(embed.fields) == 1 + assert embed.fields[0].name == "Gained Gold" + + def test_gold_lost(self): + embed = discord.Embed() + with patch("src.gw2.cogs.sessions.gw2_utils.format_gold", return_value="5 Gold"): + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_gold_field(embed, {"gold": 200}, {"gold": 100}) + assert len(embed.fields) == 1 + assert embed.fields[0].name == "Lost Gold" + + def test_gold_unchanged(self): + embed = discord.Embed() + _add_gold_field(embed, {"gold": 100}, {"gold": 100}) + assert len(embed.fields) == 0 + + def test_gold_missing_defaults_to_zero(self): + embed = discord.Embed() + _add_gold_field(embed, {}, {}) + assert len(embed.fields) == 0 + + +class TestAddDeathsField: + """Test the _add_deaths_field helper function.""" + + def test_deaths_changed(self): + embed = discord.Embed() + start = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] + end = [{"name": "Char1", "profession": "Warrior", "deaths": 15}] + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_deaths_field(embed, start, end) + assert len(embed.fields) == 1 + assert "Total:5" in embed.fields[0].value + + def test_no_deaths(self): + embed = discord.Embed() + start = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] + end = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] + _add_deaths_field(embed, start, end) + assert len(embed.fields) == 0 + + def test_empty_end_chars(self): + embed = discord.Embed() + start = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] + _add_deaths_field(embed, start, []) + assert len(embed.fields) == 0 + + def test_none_end_chars(self): + embed = discord.Embed() + start = [{"name": "Char1", "profession": "Warrior", "deaths": 10}] + _add_deaths_field(embed, start, None) + assert len(embed.fields) == 0 + + +class TestAddWvwStats: + """Test the _add_wvw_stats helper function.""" + + def test_all_stats_changed(self): + embed = discord.Embed() + start = { + "wvw_rank": 10, + "yaks": 5, + "yaks_scorted": 3, + "players": 10, + "keeps": 1, + "towers": 2, + "camps": 3, + "castles": 0, + } + end = { + "wvw_rank": 12, + "yaks": 8, + "yaks_scorted": 5, + "players": 15, + "keeps": 3, + "towers": 4, + "camps": 6, + "castles": 1, + } + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_wvw_stats(embed, start, end) + assert len(embed.fields) == 8 + + def test_no_stats_changed(self): + embed = discord.Embed() + stats = { + "wvw_rank": 10, + "yaks": 5, + "yaks_scorted": 3, + "players": 10, + "keeps": 1, + "towers": 2, + "camps": 3, + "castles": 0, + } + _add_wvw_stats(embed, stats, stats) + assert len(embed.fields) == 0 + + def test_missing_keys_default_to_zero(self): + embed = discord.Embed() + _add_wvw_stats(embed, {}, {}) + assert len(embed.fields) == 0 + + +class TestAddWalletCurrencyFields: + """Test the _add_wallet_currency_fields helper function.""" + + def test_currency_gained(self): + embed = discord.Embed() + start = {"karma": 100, "laurels": 10} + end = {"karma": 200, "laurels": 15} + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_wallet_currency_fields(embed, start, end) + field_names = [f.name for f in embed.fields] + assert "Gained Karma" in field_names + assert "Gained Laurels" in field_names + + def test_currency_lost(self): + embed = discord.Embed() + start = {"karma": 200} + end = {"karma": 100} + with patch("src.gw2.cogs.sessions.chat_formatting.inline", side_effect=lambda x: f"`{x}`"): + _add_wallet_currency_fields(embed, start, end) + field = next((f for f in embed.fields if f.name == "Lost Karma"), None) + assert field is not None + assert "-100" in field.value + + def test_gold_is_skipped(self): + embed = discord.Embed() + start = {"gold": 100} + end = {"gold": 200} + _add_wallet_currency_fields(embed, start, end) + assert len(embed.fields) == 0 + + def test_no_change(self): + embed = discord.Embed() + start = {"karma": 100} + end = {"karma": 100} + _add_wallet_currency_fields(embed, start, end) + assert len(embed.fields) == 0 class TestSessionSetup: @@ -1941,9 +904,7 @@ async def test_setup_removes_gw2_command(self): mock_bot = MagicMock() mock_bot.remove_command = MagicMock() mock_bot.add_cog = AsyncMock() - await setup(mock_bot) - mock_bot.remove_command.assert_called_once_with("gw2") @pytest.mark.asyncio @@ -1952,9 +913,7 @@ async def test_setup_adds_cog(self): mock_bot = MagicMock() mock_bot.remove_command = MagicMock() mock_bot.add_cog = AsyncMock() - await setup(mock_bot) - mock_bot.add_cog.assert_called_once() cog_instance = mock_bot.add_cog.call_args[0][0] assert isinstance(cog_instance, GW2Session) diff --git a/tests/unit/gw2/constants/test_gw2_currencies.py b/tests/unit/gw2/constants/test_gw2_currencies.py new file mode 100644 index 00000000..5bd45dca --- /dev/null +++ b/tests/unit/gw2/constants/test_gw2_currencies.py @@ -0,0 +1,199 @@ +"""Tests for GW2 currency and achievement ID mappings.""" + +from src.gw2.constants.gw2_currencies import ACHIEVEMENT_MAPPING, WALLET_DISPLAY_NAMES, WALLET_MAPPING + + +class TestWalletMapping: + """Test cases for WALLET_MAPPING constant.""" + + def test_is_dict_of_int_to_str(self): + assert isinstance(WALLET_MAPPING, dict) + for key, value in WALLET_MAPPING.items(): + assert isinstance(key, int), f"Key {key} is not int" + assert isinstance(value, str), f"Value {value} for key {key} is not str" + + def test_no_duplicate_values(self): + values = list(WALLET_MAPPING.values()) + assert len(values) == len(set(values)), f"Duplicate values found: {[v for v in values if values.count(v) > 1]}" + + def test_all_keys_are_positive(self): + for key in WALLET_MAPPING: + assert key > 0, f"Key {key} is not positive" + + def test_all_values_are_snake_case(self): + for value in WALLET_MAPPING.values(): + assert value == value.lower(), f"Value {value} is not lowercase" + assert " " not in value, f"Value {value} contains spaces" + + def test_contains_gold(self): + assert 1 in WALLET_MAPPING + assert WALLET_MAPPING[1] == "gold" + + def test_contains_karma(self): + assert 2 in WALLET_MAPPING + assert WALLET_MAPPING[2] == "karma" + + def test_contains_gems(self): + assert 4 in WALLET_MAPPING + assert WALLET_MAPPING[4] == "gems" + + def test_contains_laurels(self): + assert 3 in WALLET_MAPPING + assert WALLET_MAPPING[3] == "laurels" + + def test_contains_spirit_shards(self): + assert 23 in WALLET_MAPPING + assert WALLET_MAPPING[23] == "spirit_shards" + + def test_contains_badges_of_honor(self): + assert 15 in WALLET_MAPPING + assert WALLET_MAPPING[15] == "badges_honor" + + def test_contains_fractal_relics(self): + assert 7 in WALLET_MAPPING + assert WALLET_MAPPING[7] == "fractal_relics" + + def test_contains_volatile_magic(self): + assert 45 in WALLET_MAPPING + assert WALLET_MAPPING[45] == "volatile_magic" + + def test_contains_unbound_magic(self): + assert 32 in WALLET_MAPPING + assert WALLET_MAPPING[32] == "unbound_magic" + + def test_contains_transmutation_charges(self): + assert 18 in WALLET_MAPPING + assert WALLET_MAPPING[18] == "transmutation_charges" + + def test_contains_wvw_tickets(self): + assert 26 in WALLET_MAPPING + assert WALLET_MAPPING[26] == "wvw_tickets" + + def test_contains_magnetite_shards(self): + assert 28 in WALLET_MAPPING + assert WALLET_MAPPING[28] == "magnetite_shards" + + def test_contains_research_notes(self): + assert 61 in WALLET_MAPPING + assert WALLET_MAPPING[61] == "research_notes" + + def test_contains_astral_acclaim(self): + assert 63 in WALLET_MAPPING + assert WALLET_MAPPING[63] == "astral_acclaim" + + def test_has_expected_count(self): + assert len(WALLET_MAPPING) == 79 + + +class TestWalletDisplayNames: + """Test cases for WALLET_DISPLAY_NAMES constant.""" + + def test_is_dict_of_str_to_str(self): + assert isinstance(WALLET_DISPLAY_NAMES, dict) + for key, value in WALLET_DISPLAY_NAMES.items(): + assert isinstance(key, str), f"Key {key} is not str" + assert isinstance(value, str), f"Value {value} for key {key} is not str" + + def test_all_keys_are_snake_case(self): + for key in WALLET_DISPLAY_NAMES: + assert key == key.lower(), f"Key {key} is not lowercase" + assert " " not in key, f"Key {key} contains spaces" + + def test_all_values_are_nonempty(self): + for key, value in WALLET_DISPLAY_NAMES.items(): + assert len(value) > 0, f"Display name for {key} is empty" + + def test_gold_display_name(self): + assert WALLET_DISPLAY_NAMES["gold"] == "Gold" + + def test_karma_display_name(self): + assert WALLET_DISPLAY_NAMES["karma"] == "Karma" + + def test_gems_display_name(self): + assert WALLET_DISPLAY_NAMES["gems"] == "Gems" + + def test_wvw_tickets_display_name(self): + assert WALLET_DISPLAY_NAMES["wvw_tickets"] == "WvW Skirmish Tickets" + + def test_research_notes_display_name(self): + assert WALLET_DISPLAY_NAMES["research_notes"] == "Research Notes" + + def test_badges_honor_display_name(self): + assert WALLET_DISPLAY_NAMES["badges_honor"] == "Badges of Honor" + + def test_has_expected_count(self): + assert len(WALLET_DISPLAY_NAMES) == 79 + + +class TestWalletMappingAndDisplayNamesConsistency: + """Test that WALLET_MAPPING and WALLET_DISPLAY_NAMES are consistent.""" + + def test_every_mapping_value_has_display_name(self): + for api_id, stat_name in WALLET_MAPPING.items(): + assert stat_name in WALLET_DISPLAY_NAMES, ( + f"WALLET_MAPPING value '{stat_name}' (API ID {api_id}) has no display name" + ) + + def test_every_display_name_key_has_mapping(self): + mapping_values = set(WALLET_MAPPING.values()) + for stat_name in WALLET_DISPLAY_NAMES: + assert stat_name in mapping_values, ( + f"WALLET_DISPLAY_NAMES key '{stat_name}' is not in WALLET_MAPPING values" + ) + + def test_same_count(self): + assert len(WALLET_MAPPING) == len(WALLET_DISPLAY_NAMES) + + +class TestAchievementMapping: + """Test cases for ACHIEVEMENT_MAPPING constant.""" + + def test_is_dict_of_int_to_str(self): + assert isinstance(ACHIEVEMENT_MAPPING, dict) + for key, value in ACHIEVEMENT_MAPPING.items(): + assert isinstance(key, int), f"Key {key} is not int" + assert isinstance(value, str), f"Value {value} for key {key} is not str" + + def test_no_duplicate_values(self): + values = list(ACHIEVEMENT_MAPPING.values()) + assert len(values) == len(set(values)) + + def test_all_keys_are_positive(self): + for key in ACHIEVEMENT_MAPPING: + assert key > 0 + + def test_contains_players_killed(self): + assert 283 in ACHIEVEMENT_MAPPING + assert ACHIEVEMENT_MAPPING[283] == "players" + + def test_contains_yaks_scorted(self): + assert 285 in ACHIEVEMENT_MAPPING + assert ACHIEVEMENT_MAPPING[285] == "yaks_scorted" + + def test_contains_yaks_killed(self): + assert 288 in ACHIEVEMENT_MAPPING + assert ACHIEVEMENT_MAPPING[288] == "yaks" + + def test_contains_camps(self): + assert 291 in ACHIEVEMENT_MAPPING + assert ACHIEVEMENT_MAPPING[291] == "camps" + + def test_contains_castles(self): + assert 294 in ACHIEVEMENT_MAPPING + assert ACHIEVEMENT_MAPPING[294] == "castles" + + def test_contains_towers(self): + assert 297 in ACHIEVEMENT_MAPPING + assert ACHIEVEMENT_MAPPING[297] == "towers" + + def test_contains_keeps(self): + assert 300 in ACHIEVEMENT_MAPPING + assert ACHIEVEMENT_MAPPING[300] == "keeps" + + def test_has_expected_count(self): + assert len(ACHIEVEMENT_MAPPING) == 7 + + def test_all_values_are_snake_case(self): + for value in ACHIEVEMENT_MAPPING.values(): + assert value == value.lower() + assert " " not in value diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index 58961b96..8928ef90 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -3,6 +3,7 @@ import discord import pytest from datetime import datetime, timedelta +from src.gw2.constants.gw2_currencies import WALLET_DISPLAY_NAMES, WALLET_MAPPING from src.gw2.tools.gw2_exceptions import APIConnectionError from src.gw2.tools.gw2_utils import ( TimeObject, @@ -22,6 +23,7 @@ earned_ap, end_session, format_gold, + format_seconds_to_time, get_pvp_rank_title, get_time_passed, get_user_stats, @@ -934,7 +936,7 @@ async def test_api_exception_returns_none(self, mock_bot): @pytest.mark.asyncio async def test_successful_stats_retrieval(self, mock_bot): """Test successful user stats retrieval with legacy wvw_rank.""" - account_data = {"name": "TestUser.1234", "wvw_rank": 50} + account_data = {"name": "TestUser.1234", "wvw_rank": 50, "age": 5000000} wallet_data = [ {"id": 1, "value": 50000}, # gold {"id": 2, "value": 100000}, # karma @@ -953,6 +955,7 @@ async def test_successful_stats_retrieval(self, mock_bot): assert result is not None assert result["acc_name"] == "TestUser.1234" + assert result["age"] == 5000000 assert result["wvw_rank"] == 50 assert result["gold"] == 50000 assert result["karma"] == 100000 @@ -989,6 +992,8 @@ async def test_stats_with_all_wallet_items(self, mock_bot): {"id": 26, "value": 60}, {"id": 31, "value": 70}, {"id": 36, "value": 80}, + {"id": 23, "value": 150}, # spirit_shards + {"id": 45, "value": 2000}, # volatile_magic ] achievements_data = [] @@ -1006,23 +1011,23 @@ async def test_stats_with_all_wallet_items(self, mock_bot): assert result["wvw_tickets"] == 60 assert result["proof_heroics"] == 70 assert result["test_heroics"] == 80 + assert result["spirit_shards"] == 150 + assert result["volatile_magic"] == 2000 class TestUpdateWalletStats: """Test cases for _update_wallet_stats function.""" + def _make_user_stats(self): + """Create a user stats dict with all wallet keys initialized to 0.""" + stats = {} + for key in WALLET_MAPPING.values(): + stats[key] = 0 + return stats + def test_updates_known_wallet_ids(self): - """Test that known wallet IDs update stats (lines 373-388).""" - user_stats = { - "gold": 0, - "karma": 0, - "laurels": 0, - "badges_honor": 0, - "guild_commendations": 0, - "wvw_tickets": 0, - "proof_heroics": 0, - "test_heroics": 0, - } + """Test that known wallet IDs update stats.""" + user_stats = self._make_user_stats() wallet_data = [ {"id": 1, "value": 50000}, {"id": 2, "value": 100000}, @@ -1045,18 +1050,28 @@ def test_updates_known_wallet_ids(self): assert user_stats["proof_heroics"] == 15 assert user_stats["test_heroics"] == 5 + def test_updates_new_wallet_ids(self): + """Test that new wallet IDs (spirit shards, volatile magic, etc.) update stats.""" + user_stats = self._make_user_stats() + wallet_data = [ + {"id": 23, "value": 150}, # spirit_shards + {"id": 18, "value": 42}, # transmutation_charges + {"id": 45, "value": 2000}, # volatile_magic + {"id": 32, "value": 800}, # unbound_magic + {"id": 4, "value": 400}, # gems + ] + + _update_wallet_stats(user_stats, wallet_data) + + assert user_stats["spirit_shards"] == 150 + assert user_stats["transmutation_charges"] == 42 + assert user_stats["volatile_magic"] == 2000 + assert user_stats["unbound_magic"] == 800 + assert user_stats["gems"] == 400 + def test_ignores_unknown_wallet_ids(self): """Test that unknown wallet IDs are ignored.""" - user_stats = { - "gold": 0, - "karma": 0, - "laurels": 0, - "badges_honor": 0, - "guild_commendations": 0, - "wvw_tickets": 0, - "proof_heroics": 0, - "test_heroics": 0, - } + user_stats = self._make_user_stats() wallet_data = [ {"id": 999, "value": 50000}, # Unknown ID {"id": 888, "value": 30000}, # Unknown ID @@ -1070,16 +1085,7 @@ def test_ignores_unknown_wallet_ids(self): def test_empty_wallet_data(self): """Test with empty wallet data.""" - user_stats = { - "gold": 0, - "karma": 0, - "laurels": 0, - "badges_honor": 0, - "guild_commendations": 0, - "wvw_tickets": 0, - "proof_heroics": 0, - "test_heroics": 0, - } + user_stats = self._make_user_stats() _update_wallet_stats(user_stats, []) @@ -1087,16 +1093,7 @@ def test_empty_wallet_data(self): def test_mixed_known_and_unknown_ids(self): """Test with a mix of known and unknown wallet IDs.""" - user_stats = { - "gold": 0, - "karma": 0, - "laurels": 0, - "badges_honor": 0, - "guild_commendations": 0, - "wvw_tickets": 0, - "proof_heroics": 0, - "test_heroics": 0, - } + user_stats = self._make_user_stats() wallet_data = [ {"id": 1, "value": 10000}, # Known - gold {"id": 999, "value": 5000}, # Unknown @@ -1722,11 +1719,12 @@ class TestCreateInitialUserStats: def test_creates_correct_structure(self): """Test that initial stats structure is correct with legacy wvw_rank.""" - account_data = {"name": "TestUser.1234", "wvw_rank": 75} + account_data = {"name": "TestUser.1234", "wvw_rank": 75, "age": 5000000} result = _create_initial_user_stats(account_data) assert result["acc_name"] == "TestUser.1234" + assert result["age"] == 5000000 assert result["wvw_rank"] == 75 assert result["gold"] == 0 assert result["karma"] == 0 @@ -1743,6 +1741,25 @@ def test_creates_correct_structure(self): assert result["castles"] == 0 assert result["towers"] == 0 assert result["keeps"] == 0 + # New currencies + assert result["spirit_shards"] == 0 + assert result["transmutation_charges"] == 0 + assert result["volatile_magic"] == 0 + assert result["unbound_magic"] == 0 + assert result["gems"] == 0 + + def test_all_wallet_currencies_initialized(self): + """Test that all currencies from WALLET_MAPPING are initialized to 0.""" + account_data = {"name": "TestUser.1234", "wvw_rank": 0} + result = _create_initial_user_stats(account_data) + for stat_name in WALLET_MAPPING.values(): + assert result[stat_name] == 0, f"{stat_name} should be initialized to 0" + + def test_age_defaults_to_zero(self): + """Test that age defaults to 0 when not in account data.""" + account_data = {"name": "TestUser.1234", "wvw_rank": 0} + result = _create_initial_user_stats(account_data) + assert result["age"] == 0 def test_new_wvw_rank_format(self): """Test that wvw.rank (new API format) is preferred over wvw_rank.""" @@ -1820,3 +1837,74 @@ async def test_unknown_wr_team_id(self, mock_ctx): assert result is not None assert result[0] == "Team 11999" + + +class TestFormatSecondsToTime: + """Test cases for format_seconds_to_time function.""" + + def test_zero_seconds(self): + assert format_seconds_to_time(0) == "0s" + + def test_negative_seconds(self): + assert format_seconds_to_time(-5) == "0s" + + def test_seconds_only(self): + assert format_seconds_to_time(45) == "45s" + + def test_minutes_and_seconds(self): + assert format_seconds_to_time(125) == "2m 5s" + + def test_hours_minutes_seconds(self): + assert format_seconds_to_time(9015) == "2h 30m 15s" + + def test_hours_only(self): + assert format_seconds_to_time(7200) == "2h" + + def test_days_hours_minutes_seconds(self): + assert format_seconds_to_time(90015) == "1d 1h 15s" + + def test_exact_day(self): + assert format_seconds_to_time(86400) == "1d" + + def test_large_value(self): + result = format_seconds_to_time(180000) + assert "2d" in result + + def test_one_second(self): + assert format_seconds_to_time(1) == "1s" + + def test_one_minute(self): + assert format_seconds_to_time(60) == "1m" + + def test_one_hour(self): + assert format_seconds_to_time(3600) == "1h" + + +class TestWalletMappingAndDisplayNames: + """Test cases for WALLET_MAPPING and WALLET_DISPLAY_NAMES consistency.""" + + def test_all_wallet_keys_have_display_names(self): + """Every stat_name in WALLET_MAPPING should have a WALLET_DISPLAY_NAMES entry.""" + for wallet_id, stat_name in WALLET_MAPPING.items(): + assert stat_name in WALLET_DISPLAY_NAMES, ( + f"Wallet ID {wallet_id} maps to '{stat_name}' but has no display name" + ) + + def test_display_names_not_empty(self): + """All display names should be non-empty strings.""" + for stat_name, display_name in WALLET_DISPLAY_NAMES.items(): + assert display_name, f"Display name for '{stat_name}' is empty" + + def test_gold_in_mapping(self): + """Gold (ID 1) must be in the wallet mapping.""" + assert 1 in WALLET_MAPPING + assert WALLET_MAPPING[1] == "gold" + + def test_known_currency_ids(self): + """Verify well-known currency IDs are mapped correctly.""" + assert WALLET_MAPPING[2] == "karma" + assert WALLET_MAPPING[3] == "laurels" + assert WALLET_MAPPING[23] == "spirit_shards" + assert WALLET_MAPPING[45] == "volatile_magic" + assert WALLET_MAPPING[32] == "unbound_magic" + assert WALLET_MAPPING[18] == "transmutation_charges" diff --git a/uv.lock b/uv.lock index 273d9478..e5b90d69 100644 --- a/uv.lock +++ b/uv.lock @@ -396,7 +396,7 @@ requires-dist = [ { name = "ddcdatabases", extras = ["postgres"], specifier = ">=3.0.10" }, { name = "discord-py", specifier = ">=2.6.4" }, { name = "gtts", specifier = ">=2.5.4" }, - { name = "openai", specifier = ">=2.22.0" }, + { name = "openai", specifier = ">=2.23.0" }, { name = "pynacl", specifier = ">=1.6.2" }, { name = "pythonlogs", specifier = ">=6.0.2" }, ] @@ -689,7 +689,7 @@ wheels = [ [[package]] name = "openai" -version = "2.22.0" +version = "2.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -701,9 +701,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/ed/0a004a42fea6b6f3dd4ab33235183e994a4c7ade214fba10d9494577ec04/openai-2.22.0.tar.gz", hash = "sha256:fc2ea71c79951ac3faf178ff72c766bb4b09c3e9aab277184c5260ab3e94294f", size = 657093, upload-time = "2026-02-23T20:14:31.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/4b/dc1d84b8237205ebe48a1b1c9c3a8e1ab9fd08b30811b6d787196df58fd6/openai-2.23.0.tar.gz", hash = "sha256:7d24cc8087d5e8eed58e98aaa823391d39d12f9a9a2755770f67c7bb2004d94c", size = 657323, upload-time = "2026-02-24T03:20:20.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9a/ac24d606ea7e729475100689a1fe8866fe6cbcd0fd9b93dc4b8324be353d/openai-2.22.0-py3-none-any.whl", hash = "sha256:df02cfb731fe312215d046bf1330030e0f4b70a7b880b96992b1517b0b6aced8", size = 1118913, upload-time = "2026-02-23T20:14:29.546Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5f/bcdf0fb510c24f021e485f920677da363cd59d6e0310171bf2cad6e052b5/openai-2.23.0-py3-none-any.whl", hash = "sha256:1041d40bebf845053fda1946104f8bf9c3e2df957a41c3878c55c72c352630e9", size = 1118971, upload-time = "2026-02-24T03:20:18.708Z" }, ] [[package]] @@ -1086,26 +1086,22 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.46" +version = "2.0.47" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, - { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, - { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, - { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, - { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, - { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, - { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, - { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323, upload-time = "2026-02-24T16:34:27.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, + { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154, upload-time = "2026-02-24T17:14:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" }, ] [package.optional-dependencies] From 263443a59d24ba3a0808593057bc036b0c1d1fdb Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:27:01 -0300 Subject: [PATCH 11/13] v3.0.4 --- uv.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uv.lock b/uv.lock index e5b90d69..08c9957b 100644 --- a/uv.lock +++ b/uv.lock @@ -1097,6 +1097,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528, upload-time = "2026-02-24T17:22:59.363Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181, upload-time = "2026-02-24T17:23:01.001Z" }, { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, From b8784256369d2918ac9d1a9834f15d4d9a41432c Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:50:26 -0300 Subject: [PATCH 12/13] v3.0.4 --- pyproject.toml | 4 ++-- uv.lock | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e12fdd04..7848c208 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,13 +57,13 @@ profile-integration = "uv run python -m cProfile -o cprofile_integration.prof -m test.sequence = [{ shell = "uv run coverage run -m pytest tests/unit" }, { shell = "uv run coverage report" }, { shell = "uv run coverage xml" }] test-integration = "uv run pytest tests/integration" hadolint.shell = "docker run --rm -i -v $(pwd)/.hadolint.yml:/.config/hadolint.yml:ro hadolint/hadolint < Dockerfile" -test-docker = "uv run coverage run -m pytest tests/docker" +test-docker = "uv run pytest tests/docker" tests.sequence = ["linter", "hadolint", "test-docker", "test", "test-integration"] updatedev.sequence = ["linter", {shell = "uv lock --upgrade && uv sync --all-extras --group dev"}] migration = "uv run --frozen alembic upgrade head" [tool.pytest.ini_options] -addopts = "-v --junitxml=junit.xml --import-mode=importlib" +addopts = "-v --import-mode=importlib --junitxml=junit.xml" junit_family = "legacy" asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" diff --git a/uv.lock b/uv.lock index 08c9957b..69ec33b5 100644 --- a/uv.lock +++ b/uv.lock @@ -1096,11 +1096,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8e wheels = [ { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, + { url = "https://files.pythonhosted.org/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6", size = 3279481, upload-time = "2026-02-24T17:27:26.491Z" }, { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, + { url = "https://files.pythonhosted.org/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f", size = 3246262, upload-time = "2026-02-24T17:27:27.986Z" }, { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528, upload-time = "2026-02-24T17:22:59.363Z" }, { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181, upload-time = "2026-02-24T17:23:01.001Z" }, { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172", size = 3528568, upload-time = "2026-02-24T17:28:03.732Z" }, { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706", size = 3478410, upload-time = "2026-02-24T17:28:05.892Z" }, { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154, upload-time = "2026-02-24T17:14:43.205Z" }, { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" }, From 479b82968281ceb908aa3ad41cfa58af5e82af6b Mon Sep 17 00:00:00 2001 From: ddc <34492089+ddc@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:53:02 -0300 Subject: [PATCH 13/13] v3.0.4 --- .github/PULL_REQUEST_TEMPLATE | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE index f6bccffa..e31472a6 100644 --- a/.github/PULL_REQUEST_TEMPLATE +++ b/.github/PULL_REQUEST_TEMPLATE @@ -1,11 +1,8 @@ ## Summary - ## Changes Made -- - ## Type of Change - [ ] Bug fix