From e33b28025e18c56764fb741a702d55f02b1692f4 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 10:30:18 +0100 Subject: [PATCH] fix: use SQLite-compatible space format in utcnow_iso(), fix credits ordering utcnow_iso() now produces 'YYYY-MM-DD HH:MM:SS' (space separator) matching SQLite's datetime('now') so lexicographic comparisons like 'published_at <= datetime(now)' work correctly. Also add `id DESC` tiebreaker to get_ledger() ORDER BY to preserve insertion order when multiple credits are added within the same second. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/admin/routes.py | 4 ++-- web/src/padelnomics/auth/routes.py | 4 ++-- web/src/padelnomics/billing/routes.py | 6 +++--- web/src/padelnomics/content/__init__.py | 2 +- web/src/padelnomics/core.py | 14 +++++++------- web/src/padelnomics/credits.py | 2 +- web/src/padelnomics/leads/routes.py | 1 - web/src/padelnomics/scripts/seed_content.py | 2 +- web/src/padelnomics/scripts/seed_dev_data.py | 14 +++++++------- web/src/padelnomics/seo/_bing.py | 4 ++-- web/src/padelnomics/seo/_gsc.py | 6 +++--- web/src/padelnomics/seo/_umami.py | 6 +++--- web/src/padelnomics/suppliers/routes.py | 2 +- web/src/padelnomics/worker.py | 6 +++--- web/tests/test_content.py | 6 ++---- web/tests/test_credits.py | 3 +-- web/tests/test_feature_flags.py | 10 ++++------ web/tests/test_seo.py | 3 +-- web/tests/test_supervisor.py | 10 +++------- web/tests/test_supplier_webhooks.py | 3 +-- 20 files changed, 48 insertions(+), 60 deletions(-) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 47913d6..c8ec9d0 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -2,7 +2,7 @@ Admin domain: role-based admin panel for managing users, tasks, etc. """ import json -from datetime import date, datetime, timedelta +from datetime import date, timedelta from pathlib import Path import mistune @@ -68,7 +68,7 @@ async def get_dashboard_stats() -> dict: """Get admin dashboard statistics.""" now = utcnow() today = now.date().isoformat() - week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%S") + week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S") users_total = await fetch_one("SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL") users_today = await fetch_one( "SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL", diff --git a/web/src/padelnomics/auth/routes.py b/web/src/padelnomics/auth/routes.py index 908ca9d..5c39f73 100644 --- a/web/src/padelnomics/auth/routes.py +++ b/web/src/padelnomics/auth/routes.py @@ -3,7 +3,7 @@ Auth domain: magic link authentication, user management, decorators. """ import secrets -from datetime import datetime, timedelta +from datetime import timedelta from functools import wraps from pathlib import Path @@ -87,7 +87,7 @@ async def create_auth_token(user_id: int, token: str, minutes: int = None) -> in expires = utcnow() + timedelta(minutes=minutes) return await execute( "INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)", - (user_id, token, expires.strftime("%Y-%m-%dT%H:%M:%S")), + (user_id, token, expires.strftime("%Y-%m-%d %H:%M:%S")), ) diff --git a/web/src/padelnomics/billing/routes.py b/web/src/padelnomics/billing/routes.py index c1c521d..5cb2f15 100644 --- a/web/src/padelnomics/billing/routes.py +++ b/web/src/padelnomics/billing/routes.py @@ -5,7 +5,7 @@ Payment provider: paddle import json import secrets -from datetime import datetime, timedelta +from datetime import timedelta from pathlib import Path from paddle_billing import Client as PaddleClient @@ -413,7 +413,7 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: # Sticky boost purchases elif key == "boost_sticky_week" and supplier_id: from ..core import transaction as db_transaction - expires = (utcnow() + timedelta(weeks=1)).strftime("%Y-%m-%dT%H:%M:%S") + expires = (utcnow() + timedelta(weeks=1)).strftime("%Y-%m-%d %H:%M:%S") country = custom_data.get("sticky_country", "") async with db_transaction() as db: await db.execute( @@ -429,7 +429,7 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: elif key == "boost_sticky_month" and supplier_id: from ..core import transaction as db_transaction - expires = (utcnow() + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%S") + expires = (utcnow() + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") country = custom_data.get("sticky_country", "") async with db_transaction() as db: await db.execute( diff --git a/web/src/padelnomics/content/__init__.py b/web/src/padelnomics/content/__init__.py index 618679b..33cbeb9 100644 --- a/web/src/padelnomics/content/__init__.py +++ b/web/src/padelnomics/content/__init__.py @@ -135,7 +135,7 @@ def _validate_table_name(data_table: str) -> None: def _datetimeformat(value: str, fmt: str = "%Y-%m-%d") -> str: """Jinja2 filter: format a date string (or 'now') with strftime.""" - from datetime import UTC, datetime + from datetime import datetime if value == "now": dt = datetime.now(UTC) diff --git a/web/src/padelnomics/core.py b/web/src/padelnomics/core.py index c762d5f..9f0895d 100644 --- a/web/src/padelnomics/core.py +++ b/web/src/padelnomics/core.py @@ -102,10 +102,10 @@ def utcnow() -> datetime: def utcnow_iso() -> str: """UTC now as naive ISO string for SQLite TEXT columns. - Produces YYYY-MM-DDTHH:MM:SS (no +00:00 suffix) to match the existing - format stored in the DB so lexicographic SQL comparisons keep working. + Produces YYYY-MM-DD HH:MM:SS (space separator, no +00:00 suffix) to match + SQLite's native datetime('now') format so lexicographic SQL comparisons work. """ - return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") # ============================================================================= @@ -554,12 +554,12 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t # Clean old entries and count recent await execute( "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", - (key, window_start.strftime("%Y-%m-%dT%H:%M:%S")), + (key, window_start.strftime("%Y-%m-%d %H:%M:%S")), ) result = await fetch_one( "SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?", - (key, window_start.strftime("%Y-%m-%dT%H:%M:%S")), + (key, window_start.strftime("%Y-%m-%d %H:%M:%S")), ) count = result["count"] if result else 0 @@ -575,7 +575,7 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t # Record this request await execute( "INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", - (key, now.strftime("%Y-%m-%dT%H:%M:%S")), + (key, now.strftime("%Y-%m-%d %H:%M:%S")), ) return True, info @@ -671,7 +671,7 @@ async def hard_delete(table: str, id: int) -> bool: async def purge_deleted(table: str, days: int = 30) -> int: """Purge records deleted more than X days ago.""" - cutoff = (utcnow() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S") + cutoff = (utcnow() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") return await execute( f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) ) diff --git a/web/src/padelnomics/credits.py b/web/src/padelnomics/credits.py index 77943f1..fa8c855 100644 --- a/web/src/padelnomics/credits.py +++ b/web/src/padelnomics/credits.py @@ -199,6 +199,6 @@ async def get_ledger(supplier_id: int, limit: int = 50) -> list[dict]: FROM credit_ledger cl LEFT JOIN lead_forwards lf ON cl.reference_id = lf.id AND cl.event_type = 'lead_unlock' WHERE cl.supplier_id = ? - ORDER BY cl.created_at DESC LIMIT ?""", + ORDER BY cl.created_at DESC, cl.id DESC LIMIT ?""", (supplier_id, limit), ) diff --git a/web/src/padelnomics/leads/routes.py b/web/src/padelnomics/leads/routes.py index a814d8e..265fc74 100644 --- a/web/src/padelnomics/leads/routes.py +++ b/web/src/padelnomics/leads/routes.py @@ -4,7 +4,6 @@ Leads domain: capture interest in court suppliers and financing. import json import secrets -from datetime import datetime from pathlib import Path from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for diff --git a/web/src/padelnomics/scripts/seed_content.py b/web/src/padelnomics/scripts/seed_content.py index 2b3090d..1b53b0a 100644 --- a/web/src/padelnomics/scripts/seed_content.py +++ b/web/src/padelnomics/scripts/seed_content.py @@ -1390,7 +1390,7 @@ def seed_templates(conn: sqlite3.Connection) -> dict[str, int]: def seed_data_rows(conn: sqlite3.Connection, template_ids: dict[str, int]) -> int: """Insert template_data rows for all cities × languages. Returns count inserted.""" - now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") + now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S") inserted = 0 en_id = template_ids.get("city-padel-cost-en") diff --git a/web/src/padelnomics/scripts/seed_dev_data.py b/web/src/padelnomics/scripts/seed_dev_data.py index 69b2830..9d9a9c2 100644 --- a/web/src/padelnomics/scripts/seed_dev_data.py +++ b/web/src/padelnomics/scripts/seed_dev_data.py @@ -303,7 +303,7 @@ def main(): else: cursor = conn.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", - ("dev@localhost", "Dev User", now.strftime("%Y-%m-%dT%H:%M:%S")), + ("dev@localhost", "Dev User", now.strftime("%Y-%m-%d %H:%M:%S")), ) dev_user_id = cursor.lastrowid print(f" Created (id={dev_user_id})") @@ -336,7 +336,7 @@ def main(): s["website"], s["description"], s["category"], s["tier"], s["credit_balance"], s["monthly_credits"], s["contact_name"], s["contact_email"], s["years_in_business"], s["project_count"], - s["service_area"], now.strftime("%Y-%m-%dT%H:%M:%S"), + s["service_area"], now.strftime("%Y-%m-%d %H:%M:%S"), ), ) supplier_ids[s["slug"]] = cursor.lastrowid @@ -349,7 +349,7 @@ def main(): ("courtbuild-spain", "supplier_growth", "maria@courtbuild.example.com", "Maria Garcia"), ("desert-padel-fze", "supplier_pro", "ahmed@desertpadel.example.com", "Ahmed Al-Rashid"), ] - period_end = (now + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%S") + period_end = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S") for slug, plan, email, name in claimed_suppliers: sid = supplier_ids.get(slug) if not sid: @@ -364,14 +364,14 @@ def main(): else: cursor = conn.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", - (email, name, now.strftime("%Y-%m-%dT%H:%M:%S")), + (email, name, now.strftime("%Y-%m-%d %H:%M:%S")), ) owner_id = cursor.lastrowid # Claim the supplier conn.execute( "UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL", - (owner_id, now.strftime("%Y-%m-%dT%H:%M:%S"), sid), + (owner_id, now.strftime("%Y-%m-%d %H:%M:%S"), sid), ) # Create billing customer record @@ -382,7 +382,7 @@ def main(): conn.execute( """INSERT INTO billing_customers (user_id, provider_customer_id, created_at) VALUES (?, ?, ?)""", - (owner_id, f"ctm_dev_{slug}", now.strftime("%Y-%m-%dT%H:%M:%S")), + (owner_id, f"ctm_dev_{slug}", now.strftime("%Y-%m-%d %H:%M:%S")), ) # Create active subscription @@ -396,7 +396,7 @@ def main(): current_period_end, created_at) VALUES (?, ?, 'active', ?, ?, ?)""", (owner_id, plan, f"sub_dev_{slug}", - period_end, now.strftime("%Y-%m-%dT%H:%M:%S")), + period_end, now.strftime("%Y-%m-%d %H:%M:%S")), ) print(f" {slug} -> owner {email} ({plan})") diff --git a/web/src/padelnomics/seo/_bing.py b/web/src/padelnomics/seo/_bing.py index 9a5cb74..df6192a 100644 --- a/web/src/padelnomics/seo/_bing.py +++ b/web/src/padelnomics/seo/_bing.py @@ -127,7 +127,7 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('bing', 'success', ?, ?, ?, ?)""", - (rows_synced, started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (rows_synced, started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) return rows_synced @@ -137,6 +137,6 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) """INSERT INTO seo_sync_log (source, status, rows_synced, error, started_at, completed_at, duration_ms) VALUES ('bing', 'failed', 0, ?, ?, ?, ?)""", - (str(exc), started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (str(exc), started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) raise diff --git a/web/src/padelnomics/seo/_gsc.py b/web/src/padelnomics/seo/_gsc.py index 7c5ee86..dbdce33 100644 --- a/web/src/padelnomics/seo/_gsc.py +++ b/web/src/padelnomics/seo/_gsc.py @@ -5,7 +5,7 @@ is synchronous, so sync runs in asyncio.to_thread(). """ import asyncio -from datetime import datetime, timedelta +from datetime import timedelta from pathlib import Path from urllib.parse import urlparse @@ -127,7 +127,7 @@ async def sync_gsc(days_back: int = 3, max_pages: int = 10) -> int: """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('gsc', 'success', ?, ?, ?, ?)""", - (rows_synced, started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (rows_synced, started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) return rows_synced @@ -137,6 +137,6 @@ async def sync_gsc(days_back: int = 3, max_pages: int = 10) -> int: """INSERT INTO seo_sync_log (source, status, rows_synced, error, started_at, completed_at, duration_ms) VALUES ('gsc', 'failed', 0, ?, ?, ?, ?)""", - (str(exc), started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (str(exc), started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) raise diff --git a/web/src/padelnomics/seo/_umami.py b/web/src/padelnomics/seo/_umami.py index cccbb8a..9a3172a 100644 --- a/web/src/padelnomics/seo/_umami.py +++ b/web/src/padelnomics/seo/_umami.py @@ -4,7 +4,7 @@ Uses bearer token auth. Self-hosted instance, no rate limits. Config already exists: UMAMI_API_URL, UMAMI_API_TOKEN, UMAMI_WEBSITE_ID. """ -from datetime import datetime, timedelta +from datetime import timedelta import httpx @@ -101,7 +101,7 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms) VALUES ('umami', 'success', ?, ?, ?, ?)""", - (rows_synced, started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (rows_synced, started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) return rows_synced @@ -111,6 +111,6 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS """INSERT INTO seo_sync_log (source, status, rows_synced, error, started_at, completed_at, duration_ms) VALUES ('umami', 'failed', 0, ?, ?, ?, ?)""", - (str(exc), started_at.strftime("%Y-%m-%dT%H:%M:%S"), utcnow_iso(), duration_ms), + (str(exc), started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms), ) raise diff --git a/web/src/padelnomics/suppliers/routes.py b/web/src/padelnomics/suppliers/routes.py index f7d2977..7846887 100644 --- a/web/src/padelnomics/suppliers/routes.py +++ b/web/src/padelnomics/suppliers/routes.py @@ -13,9 +13,9 @@ from ..core import ( config, csrf_protect, execute, + feature_gate, fetch_all, fetch_one, - feature_gate, get_paddle_price, is_flag_enabled, ) diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py index e8f8b8d..f7da534 100644 --- a/web/src/padelnomics/worker.py +++ b/web/src/padelnomics/worker.py @@ -142,7 +142,7 @@ async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) ( task_name, json.dumps(payload or {}), - (run_at or utcnow()).strftime("%Y-%m-%dT%H:%M:%S"), + (run_at or utcnow()).strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), ), ) @@ -401,7 +401,7 @@ async def handle_cleanup_tokens(payload: dict) -> None: @task("cleanup_rate_limits") async def handle_cleanup_rate_limits(payload: dict) -> None: """Clean up old rate limit entries.""" - cutoff = (utcnow() - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") + cutoff = (utcnow() - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S") await execute("DELETE FROM rate_limits WHERE timestamp < ?", (cutoff,)) @@ -674,7 +674,7 @@ async def handle_generate_business_plan(payload: dict) -> None: @task("cleanup_old_tasks") async def handle_cleanup_tasks(payload: dict) -> None: """Clean up completed/failed tasks older than 7 days.""" - cutoff = (utcnow() - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%S") + cutoff = (utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S") await execute( "DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", (cutoff,) ) diff --git a/web/tests/test_content.py b/web/tests/test_content.py index 4e5cbcf..02cc103 100644 --- a/web/tests/test_content.py +++ b/web/tests/test_content.py @@ -8,9 +8,7 @@ sitemap integration, admin CRUD routes, and path collision prevention. import importlib import json import sqlite3 -from datetime import date, datetime - -from padelnomics.core import utcnow_iso +from datetime import date from pathlib import Path import pytest @@ -21,7 +19,7 @@ from padelnomics.content.routes import ( bake_scenario_cards, is_reserved_path, ) -from padelnomics.core import execute, fetch_all, fetch_one, slugify +from padelnomics.core import execute, fetch_all, fetch_one, slugify, utcnow_iso from padelnomics.planner.calculator import calc, validate_state SCHEMA_PATH = Path(__file__).parent.parent / "src" / "padelnomics" / "migrations" / "schema.sql" diff --git a/web/tests/test_credits.py b/web/tests/test_credits.py index 5599a7c..12b9826 100644 --- a/web/tests/test_credits.py +++ b/web/tests/test_credits.py @@ -3,9 +3,8 @@ Tests for the credit system (credits.py). Pure SQL operations against real in-memory SQLite — no mocking needed. """ -from padelnomics.core import utcnow_iso - import pytest +from padelnomics.core import utcnow_iso from padelnomics.credits import ( InsufficientCredits, add_credits, diff --git a/web/tests/test_feature_flags.py b/web/tests/test_feature_flags.py index b467c6b..df822bb 100644 --- a/web/tests/test_feature_flags.py +++ b/web/tests/test_feature_flags.py @@ -7,16 +7,13 @@ Integration tests exercise full request/response flows via Quart test client. """ import sqlite3 - -from padelnomics.core import utcnow_iso -from pathlib import Path from unittest.mock import AsyncMock, patch import pytest - -from padelnomics import core +from padelnomics.core import utcnow_iso from padelnomics.migrations.migrate import migrate +from padelnomics import core # ── Fixtures & helpers ──────────────────────────────────────────── @@ -294,8 +291,9 @@ class TestLeadUnlockGate: @pytest.mark.asyncio async def test_route_imports_is_flag_enabled(self): """suppliers/routes.py imports is_flag_enabled (gate is wired up).""" - from padelnomics.suppliers.routes import unlock_lead import inspect + + from padelnomics.suppliers.routes import unlock_lead src = inspect.getsource(unlock_lead) assert "is_flag_enabled" in src assert "lead_unlock" in src diff --git a/web/tests/test_seo.py b/web/tests/test_seo.py index 64dc5b6..06fb56a 100644 --- a/web/tests/test_seo.py +++ b/web/tests/test_seo.py @@ -1,11 +1,10 @@ """Tests for the SEO metrics module: queries, sync functions, admin routes.""" from datetime import UTC, datetime, timedelta - -from padelnomics.core import utcnow_iso from unittest.mock import AsyncMock, MagicMock, patch import pytest +from padelnomics.core import utcnow_iso from padelnomics.seo._queries import ( cleanup_old_metrics, get_article_scorecard, diff --git a/web/tests/test_supervisor.py b/web/tests/test_supervisor.py index 8f6eb3f..8a291db 100644 --- a/web/tests/test_supervisor.py +++ b/web/tests/test_supervisor.py @@ -8,19 +8,16 @@ supervisor.py lives in src/padelnomics/ (not a uv workspace package), so we add src/ to sys.path before importing. """ -import sys +# Load supervisor.py directly by path — avoids clashing with the web app's +# 'padelnomics' namespace (which is the installed web package). +import importlib.util as _ilu import textwrap -import tomllib from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import MagicMock, patch import pytest -# Load supervisor.py directly by path — avoids clashing with the web app's -# 'padelnomics' namespace (which is the installed web package). -import importlib.util as _ilu - _SUP_PATH = Path(__file__).parent.parent.parent / "src" / "padelnomics" / "supervisor.py" _spec = _ilu.spec_from_file_location("padelnomics_supervisor", _SUP_PATH) sup = _ilu.module_from_spec(_spec) @@ -32,7 +29,6 @@ from padelnomics_extract.proxy import ( make_sticky_selector, ) - # ── load_workflows ──────────────────────────────────────────────── diff --git a/web/tests/test_supplier_webhooks.py b/web/tests/test_supplier_webhooks.py index 2f1d1da..8322c1c 100644 --- a/web/tests/test_supplier_webhooks.py +++ b/web/tests/test_supplier_webhooks.py @@ -6,12 +6,11 @@ Uses the existing client, db, sign_payload from conftest. """ import json from datetime import UTC, datetime - -from padelnomics.core import utcnow_iso from unittest.mock import AsyncMock, patch import pytest from conftest import sign_payload +from padelnomics.core import utcnow_iso WEBHOOK_PATH = "/billing/webhook/paddle" SIG_HEADER = "Paddle-Signature"