From 4bdccb65e968221e907e571e3d2fb7ae5a9303f2 Mon Sep 17 00:00:00 2001 From: Deeman Date: Mon, 23 Feb 2026 15:08:13 +0100 Subject: [PATCH] =?UTF-8?q?test:=20add=2041=20tests=20for=20SEO/GEO=20hub?= =?UTF-8?q?=20=E2=80=94=20sync,=20queries,=20admin=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all query functions (search perf, funnel, scorecard), sync functions (umami with mocked httpx, bing/gsc skip tests), admin route rendering, CSRF-protected sync POST, and boundary validation. Co-Authored-By: Claude Opus 4.6 --- uv.lock | 226 +++++++++++++ web/src/padelnomics/seo/_bing.py | 1 - web/src/padelnomics/seo/_gsc.py | 2 - web/src/padelnomics/seo/_umami.py | 1 - web/tests/test_seo.py | 523 ++++++++++++++++++++++++++++++ 5 files changed, 749 insertions(+), 4 deletions(-) create mode 100644 web/tests/test_seo.py diff --git a/uv.lock b/uv.lock index f8029e2..a762e41 100644 --- a/uv.lock +++ b/uv.lock @@ -356,6 +356,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + [[package]] name = "cssselect2" version = "0.9.0" @@ -511,6 +570,77 @@ woff = [ { name = "zopfli" }, ] +[[package]] +name = "google-api-core" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.190.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/4ab3e3516b93bb50ed7814738ea61d49cba3f72f4e331dc9518ae2731e92/google_api_python_client-2.190.0.tar.gz", hash = "sha256:5357f34552e3724d80d2604c8fa146766e0a9d6bb0afada886fafed9feafeef6", size = 14111143, upload-time = "2026-02-12T00:38:03.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ad/223d5f4b0b987669ffeb3eadd7e9f85ece633aa7fd3246f1e2f6238e1e05/google_api_python_client-2.190.0-py3-none-any.whl", hash = "sha256:d9b5266758f96c39b8c21d9bbfeb4e58c14dbfba3c931f7c5a8d7fdcd292dd57", size = 14682070, upload-time = "2026-02-12T00:38:00.974Z" }, +] + +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + [[package]] name = "greenlet" version = "3.3.1" @@ -607,6 +737,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httplib2" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1152,6 +1294,9 @@ source = { editable = "web" } dependencies = [ { name = "aiosqlite" }, { name = "duckdb" }, + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "httpx" }, { name = "hypercorn" }, { name = "itsdangerous" }, { name = "jinja2" }, @@ -1168,6 +1313,9 @@ dependencies = [ requires-dist = [ { name = "aiosqlite", specifier = ">=0.19.0" }, { name = "duckdb", specifier = ">=1.0.0" }, + { name = "google-api-python-client", specifier = ">=2.100.0" }, + { name = "google-auth", specifier = ">=2.23.0" }, + { name = "httpx", specifier = ">=0.27.0" }, { name = "hypercorn", specifier = ">=0.17.0" }, { name = "itsdangerous", specifier = ">=2.1.0" }, { name = "jinja2", specifier = ">=3.1.0" }, @@ -1406,6 +1554,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -1424,6 +1599,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1575,6 +1771,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pyphen" version = "0.17.2" @@ -1987,6 +2192,18 @@ jupyter = [ { name = "ipywidgets" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruamel-yaml" version = "0.19.1" @@ -2331,6 +2548,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" diff --git a/web/src/padelnomics/seo/_bing.py b/web/src/padelnomics/seo/_bing.py index 8effc5a..5a76446 100644 --- a/web/src/padelnomics/seo/_bing.py +++ b/web/src/padelnomics/seo/_bing.py @@ -10,7 +10,6 @@ import httpx from ..core import config, execute - _TIMEOUT_SECONDS = 30 diff --git a/web/src/padelnomics/seo/_gsc.py b/web/src/padelnomics/seo/_gsc.py index 9753160..83fa70e 100644 --- a/web/src/padelnomics/seo/_gsc.py +++ b/web/src/padelnomics/seo/_gsc.py @@ -5,14 +5,12 @@ is synchronous, so sync runs in asyncio.to_thread(). """ import asyncio -import time from datetime import datetime, timedelta from pathlib import Path from urllib.parse import urlparse from ..core import config, execute - # GSC returns max 25K rows per request _ROWS_PER_PAGE = 25_000 diff --git a/web/src/padelnomics/seo/_umami.py b/web/src/padelnomics/seo/_umami.py index 33a7083..c35f357 100644 --- a/web/src/padelnomics/seo/_umami.py +++ b/web/src/padelnomics/seo/_umami.py @@ -10,7 +10,6 @@ import httpx from ..core import config, execute - _TIMEOUT_SECONDS = 15 diff --git a/web/tests/test_seo.py b/web/tests/test_seo.py new file mode 100644 index 0000000..708ee51 --- /dev/null +++ b/web/tests/test_seo.py @@ -0,0 +1,523 @@ +"""Tests for the SEO metrics module: queries, sync functions, admin routes.""" + +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from padelnomics.seo._queries import ( + cleanup_old_metrics, + get_article_scorecard, + get_country_breakdown, + get_device_breakdown, + get_funnel_metrics, + get_search_performance, + get_sync_status, + get_top_pages, + get_top_queries, +) + +from padelnomics import core + +# ── Fixtures ────────────────────────────────────────────────── + +def _today(): + return datetime.utcnow().strftime("%Y-%m-%d") + + +def _days_ago(n: int) -> str: + return (datetime.utcnow() - timedelta(days=n)).strftime("%Y-%m-%d") + + +@pytest.fixture +async def seo_data(db): + """Populate seo_search_metrics and seo_analytics_metrics with sample data.""" + today = _today() + yesterday = _days_ago(1) + + # GSC search data + rows = [ + ("gsc", today, "/en/markets/germany/berlin", "padel berlin", "de", "mobile", 50, 500, 0.10, 5.2), + ("gsc", today, "/en/markets/germany/munich", "padel munich", "de", "desktop", 30, 300, 0.10, 8.1), + ("gsc", today, "/en/markets/germany/berlin", "padel court cost", "de", "desktop", 10, 200, 0.05, 12.0), + ("gsc", yesterday, "/en/markets/germany/berlin", "padel berlin", "de", "mobile", 45, 480, 0.09, 5.5), + # Bing data + ("bing", today, "/", "padel business plan", None, None, 5, 100, 0.05, 15.0), + ] + for source, d, page, query, country, device, clicks, imp, ctr, pos in rows: + await db.execute( + """INSERT INTO seo_search_metrics + (source, metric_date, page_url, query, country, device, + clicks, impressions, ctr, position_avg) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (source, d, page, query, country, device, clicks, imp, ctr, pos), + ) + + # Umami analytics data + analytics_rows = [ + (today, "/en/markets/germany/berlin", 120, 80, 0.35, 45), + (today, "/en/markets/germany/munich", 60, 40, 0.40, 30), + (today, "/", 200, 150, 0.50, 20), + ] + for d, page, pv, vis, br, t in analytics_rows: + await db.execute( + """INSERT INTO seo_analytics_metrics + (metric_date, page_url, pageviews, visitors, bounce_rate, time_avg_seconds) + VALUES (?, ?, ?, ?, ?, ?)""", + (d, page, pv, vis, br, t), + ) + + await db.commit() + + +@pytest.fixture +async def articles_data(db, seo_data): + """Create articles that match the SEO data URLs.""" + now = datetime.utcnow().isoformat() + pub = _days_ago(10) + + for title, url, tpl, lang in [ + ("Padel in Berlin", "/en/markets/germany/berlin", "city-cost-de", "en"), + ("Padel in Munich", "/en/markets/germany/munich", "city-cost-de", "en"), + ]: + await db.execute( + """INSERT INTO articles + (url_path, slug, title, template_slug, language, status, published_at, created_at) + VALUES (?, ?, ?, ?, ?, 'published', ?, ?)""", + (url, url.split("/")[-1], title, tpl, lang, pub, now), + ) + await db.commit() + + +@pytest.fixture +async def admin_client(app, db): + """Authenticated admin client.""" + now = datetime.utcnow().isoformat() + async with db.execute( + "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", + ("admin@test.com", "Admin", now), + ) as cursor: + admin_id = cursor.lastrowid + await db.execute( + "INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,) + ) + await db.commit() + + async with app.test_client() as c: + async with c.session_transaction() as sess: + sess["user_id"] = admin_id + yield c + + +# ── Query function tests ───────────────────────────────────── + +class TestSearchPerformance: + """Tests for get_search_performance().""" + + async def test_returns_aggregate_metrics(self, db, seo_data): + result = await get_search_performance(date_range_days=28) + assert result["total_clicks"] > 0 + assert result["total_impressions"] > 0 + assert 0 < result["avg_ctr"] < 1 + assert result["avg_position"] > 0 + + async def test_filter_by_source(self, db, seo_data): + gsc = await get_search_performance(date_range_days=28, source="gsc") + bing = await get_search_performance(date_range_days=28, source="bing") + combined = await get_search_performance(date_range_days=28) + assert gsc["total_clicks"] + bing["total_clicks"] == combined["total_clicks"] + + async def test_empty_data(self, db): + result = await get_search_performance(date_range_days=28) + assert result["total_clicks"] == 0 + assert result["total_impressions"] == 0 + + async def test_date_range_filter(self, db, seo_data): + # Only today's data should match 1-day range + result = await get_search_performance(date_range_days=1) + # Yesterday's data excluded — fewer total clicks + full = await get_search_performance(date_range_days=28) + assert result["total_clicks"] <= full["total_clicks"] + + +class TestTopQueries: + """Tests for get_top_queries().""" + + async def test_returns_queries_sorted_by_impressions(self, db, seo_data): + queries = await get_top_queries(date_range_days=28) + assert len(queries) > 0 + # Should be sorted desc by impressions + for i in range(len(queries) - 1): + assert queries[i]["impressions"] >= queries[i + 1]["impressions"] + + async def test_limit(self, db, seo_data): + queries = await get_top_queries(date_range_days=28, limit=2) + assert len(queries) <= 2 + + async def test_filter_by_source(self, db, seo_data): + gsc_queries = await get_top_queries(source="gsc") + for q in gsc_queries: + assert q["query"] != "padel business plan" # that's bing data + + +class TestTopPages: + """Tests for get_top_pages().""" + + async def test_returns_pages(self, db, seo_data): + pages = await get_top_pages(date_range_days=28) + assert len(pages) > 0 + # Berlin page should be first (most impressions) + assert pages[0]["page_url"] == "/en/markets/germany/berlin" + + +class TestCountryBreakdown: + """Tests for get_country_breakdown().""" + + async def test_returns_countries(self, db, seo_data): + countries = await get_country_breakdown(date_range_days=28) + assert len(countries) > 0 + assert any(c["country"] == "de" for c in countries) + + +class TestDeviceBreakdown: + """Tests for get_device_breakdown().""" + + async def test_returns_devices_gsc_only(self, db, seo_data): + devices = await get_device_breakdown(date_range_days=28) + assert len(devices) > 0 + device_names = [d["device"] for d in devices] + assert "mobile" in device_names + assert "desktop" in device_names + + +class TestFunnelMetrics: + """Tests for get_funnel_metrics().""" + + async def test_returns_all_stages(self, db, seo_data): + funnel = await get_funnel_metrics(date_range_days=28) + assert "impressions" in funnel + assert "clicks" in funnel + assert "pageviews" in funnel + assert "visitors" in funnel + assert "planner_users" in funnel + assert "leads" in funnel + + async def test_conversion_rates(self, db, seo_data): + funnel = await get_funnel_metrics(date_range_days=28) + assert funnel["ctr"] > 0 # We have clicks and impressions + assert 0 <= funnel["ctr"] <= 1 + + async def test_empty_data(self, db): + funnel = await get_funnel_metrics(date_range_days=28) + assert funnel["impressions"] == 0 + assert funnel["planner_users"] == 0 + + +class TestArticleScorecard: + """Tests for get_article_scorecard().""" + + async def test_joins_articles_with_metrics(self, db, articles_data): + scorecard = await get_article_scorecard(date_range_days=28) + assert len(scorecard) == 2 + berlin = next(a for a in scorecard if "berlin" in a["url_path"]) + assert berlin["impressions"] > 0 + assert berlin["pageviews"] > 0 + + async def test_filter_by_template(self, db, articles_data): + scorecard = await get_article_scorecard( + date_range_days=28, template_slug="city-cost-de", + ) + assert len(scorecard) == 2 + for a in scorecard: + assert a["template_slug"] == "city-cost-de" + + async def test_sort_by_clicks(self, db, articles_data): + scorecard = await get_article_scorecard( + date_range_days=28, sort_by="clicks", sort_dir="desc", + ) + if len(scorecard) >= 2: + assert scorecard[0]["clicks"] >= scorecard[1]["clicks"] + + async def test_attention_flags(self, db, articles_data): + """Berlin has >100 impressions and low CTR — should flag.""" + scorecard = await get_article_scorecard(date_range_days=28) + berlin = next(a for a in scorecard if "berlin" in a["url_path"]) + # Berlin: 1180 impressions total, 105 clicks → CTR ~8.9% → no flag + # Flags depend on actual data; just check fields exist + assert "flag_low_ctr" in berlin + assert "flag_no_clicks" in berlin + + async def test_invalid_sort_defaults_to_impressions(self, db, articles_data): + scorecard = await get_article_scorecard( + date_range_days=28, sort_by="invalid_column", + ) + # Should not crash — falls back to impressions + assert len(scorecard) >= 0 + + +class TestSyncStatus: + """Tests for get_sync_status().""" + + async def test_returns_last_sync_per_source(self, db): + now = datetime.utcnow().isoformat() + await db.execute( + """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) + VALUES ('gsc', 'success', 100, ?, ?, 500)""", + (now, now), + ) + await db.execute( + """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) + VALUES ('umami', 'failed', 0, ?, ?, 200)""", + (now, now), + ) + await db.commit() + + status = await get_sync_status() + assert len(status) == 2 + sources = {s["source"] for s in status} + assert "gsc" in sources + assert "umami" in sources + + async def test_empty_when_no_syncs(self, db): + status = await get_sync_status() + assert status == [] + + +class TestCleanupOldMetrics: + """Tests for cleanup_old_metrics().""" + + async def test_deletes_old_data(self, db): + old_date = (datetime.utcnow() - timedelta(days=400)).strftime("%Y-%m-%d") + recent_date = _today() + + await db.execute( + """INSERT INTO seo_search_metrics + (source, metric_date, page_url, clicks, impressions) + VALUES ('gsc', ?, '/old', 1, 10)""", + (old_date,), + ) + await db.execute( + """INSERT INTO seo_search_metrics + (source, metric_date, page_url, clicks, impressions) + VALUES ('gsc', ?, '/recent', 1, 10)""", + (recent_date,), + ) + await db.commit() + + deleted = await cleanup_old_metrics(retention_days=365) + assert deleted >= 1 + + rows = await core.fetch_all("SELECT * FROM seo_search_metrics") + assert len(rows) == 1 + assert rows[0]["page_url"] == "/recent" + + +# ── Sync function tests (mocked HTTP) ──────────────────────── + +class TestSyncUmami: + """Tests for sync_umami() with mocked HTTP.""" + + async def test_skips_when_not_configured(self, db): + original = core.config.UMAMI_API_TOKEN + core.config.UMAMI_API_TOKEN = "" + try: + from padelnomics.seo._umami import sync_umami + result = await sync_umami(days_back=1) + assert result == 0 + finally: + core.config.UMAMI_API_TOKEN = original + + async def test_syncs_url_metrics(self, db): + from padelnomics.seo._umami import sync_umami + + core.config.UMAMI_API_TOKEN = "test-token" + core.config.UMAMI_API_URL = "https://umami.test.io" + + mock_metrics = [ + {"x": "/en/markets/germany/berlin", "y": 50}, + {"x": "/en/markets/germany/munich", "y": 30}, + ] + mock_stats = { + "visitors": {"value": 100}, + "bounces": {"value": 30}, + "totaltime": {"value": 5000}, + "pageviews": {"value": 200}, + } + + mock_response_metrics = MagicMock() + mock_response_metrics.status_code = 200 + mock_response_metrics.json.return_value = mock_metrics + mock_response_metrics.raise_for_status = MagicMock() + + mock_response_stats = MagicMock() + mock_response_stats.status_code = 200 + mock_response_stats.json.return_value = mock_stats + mock_response_stats.raise_for_status = MagicMock() + + async def mock_get(url, **kwargs): + if "/metrics" in url: + return mock_response_metrics + return mock_response_stats + + mock_client = AsyncMock() + mock_client.get = mock_get + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("padelnomics.seo._umami.httpx.AsyncClient", return_value=mock_client): + result = await sync_umami(days_back=1) + + assert result == 2 # 2 URL metrics + + # Verify data stored + rows = await core.fetch_all( + "SELECT * FROM seo_analytics_metrics WHERE page_url != '/'" + ) + assert len(rows) == 2 + + # Verify sync log + log = await core.fetch_all("SELECT * FROM seo_sync_log WHERE source = 'umami'") + assert len(log) == 1 + assert log[0]["status"] == "success" + + +class TestSyncBing: + """Tests for sync_bing() with mocked HTTP.""" + + async def test_skips_when_not_configured(self, db): + original_key = core.config.BING_WEBMASTER_API_KEY + core.config.BING_WEBMASTER_API_KEY = "" + try: + from padelnomics.seo._bing import sync_bing + result = await sync_bing(days_back=1) + assert result == 0 + finally: + core.config.BING_WEBMASTER_API_KEY = original_key + + +class TestSyncGsc: + """Tests for sync_gsc() with mocked Google API.""" + + async def test_skips_when_not_configured(self, db): + original = core.config.GSC_SERVICE_ACCOUNT_PATH + core.config.GSC_SERVICE_ACCOUNT_PATH = "" + try: + from padelnomics.seo._gsc import sync_gsc + result = await sync_gsc(days_back=1) + assert result == 0 + finally: + core.config.GSC_SERVICE_ACCOUNT_PATH = original + + +# ── Admin route tests ───────────────────────────────────────── + +class TestSeoAdminRoutes: + """Tests for the SEO hub admin routes.""" + + async def test_seo_hub_loads(self, admin_client, db): + resp = await admin_client.get("/admin/seo") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "SEO" in text + + async def test_seo_hub_with_data(self, admin_client, db, seo_data): + resp = await admin_client.get("/admin/seo?days=28") + assert resp.status_code == 200 + + async def test_seo_search_partial(self, admin_client, db, seo_data): + resp = await admin_client.get("/admin/seo/search?days=28") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "Top Queries" in text + + async def test_seo_search_filter_by_source(self, admin_client, db, seo_data): + resp = await admin_client.get("/admin/seo/search?days=28&source=gsc") + assert resp.status_code == 200 + + async def test_seo_funnel_partial(self, admin_client, db, seo_data): + resp = await admin_client.get("/admin/seo/funnel?days=28") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "Impressions" in text + + async def test_seo_scorecard_partial(self, admin_client, db, articles_data): + resp = await admin_client.get("/admin/seo/scorecard?days=28") + assert resp.status_code == 200 + text = await resp.get_data(as_text=True) + assert "Berlin" in text or "scorecard" in text.lower() or "articles" in text.lower() + + async def test_seo_scorecard_filter(self, admin_client, db, articles_data): + resp = await admin_client.get( + "/admin/seo/scorecard?days=28&template_slug=city-cost-de&sort=clicks&dir=desc" + ) + assert resp.status_code == 200 + + async def test_seo_sync_requires_auth(self, client, db): + resp = await client.post("/admin/seo/sync") + # Should redirect to login (302) or return 403 + assert resp.status_code in (302, 403) + + async def test_seo_sync_now(self, admin_client, db): + """Sync Now enqueues tasks.""" + async with admin_client.session_transaction() as sess: + sess["csrf_token"] = "test" + + resp = await admin_client.post( + "/admin/seo/sync", + form={"source": "all", "csrf_token": "test"}, + ) + # Should redirect back to SEO hub + assert resp.status_code == 302 + + # Verify tasks enqueued + tasks = await core.fetch_all( + "SELECT task_name FROM tasks WHERE task_name LIKE 'sync_%'" + ) + task_names = {t["task_name"] for t in tasks} + assert "sync_gsc" in task_names + assert "sync_bing" in task_names + assert "sync_umami" in task_names + + async def test_seo_sync_single_source(self, admin_client, db): + async with admin_client.session_transaction() as sess: + sess["csrf_token"] = "test" + + resp = await admin_client.post( + "/admin/seo/sync", + form={"source": "gsc", "csrf_token": "test"}, + ) + assert resp.status_code == 302 + + tasks = await core.fetch_all("SELECT task_name FROM tasks WHERE task_name = 'sync_gsc'") + assert len(tasks) == 1 + + async def test_seo_hub_date_range(self, admin_client, db, seo_data): + for days in [7, 28, 90, 365]: + resp = await admin_client.get(f"/admin/seo?days={days}") + assert resp.status_code == 200 + + async def test_seo_sidebar_link(self, admin_client, db): + resp = await admin_client.get("/admin/") + text = await resp.get_data(as_text=True) + assert "SEO Hub" in text + + +# ── Assertion boundary tests ───────────────────────────────── + +class TestQueryBounds: + """Test that query functions validate their bounds.""" + + async def test_search_performance_rejects_zero_days(self, db): + with pytest.raises(AssertionError): + await get_search_performance(date_range_days=0) + + async def test_top_queries_rejects_zero_limit(self, db): + with pytest.raises(AssertionError): + await get_top_queries(limit=0) + + async def test_cleanup_rejects_short_retention(self, db): + with pytest.raises(AssertionError): + await cleanup_old_metrics(retention_days=7) + + async def test_scorecard_rejects_invalid_sort_dir(self, db): + with pytest.raises(AssertionError): + await get_article_scorecard(sort_dir="invalid")