From e05b700ed03422fae0f344c26f65f348005dd689 Mon Sep 17 00:00:00 2001 From: Jonas Linter <{email_address}> Date: Mon, 1 Dec 2025 10:14:14 +0100 Subject: [PATCH] Fixed some tests and added schemas --- src/alpine_bits_python/api.py | 10 +- src/alpine_bits_python/schemas.py | 146 ++++++++++++++++++++ tests/test_schemas_webhook.py | 218 ++++++++++++++++++++++++++++++ tests/test_webhook_duplicates.py | 52 +++---- 4 files changed, 399 insertions(+), 27 deletions(-) create mode 100644 tests/test_schemas_webhook.py diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 1c57335..8c44ee5 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -32,6 +32,8 @@ from sqlalchemy import and_, select, update from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.orm import selectinload +from alpine_bits_python.schemas import WebhookRequestData + from .alpinebits_server import ( AlpineBitsActionName, AlpineBitsClientInfo, @@ -888,8 +890,9 @@ async def handle_webhook_unified( webhook_request.status = WebhookStatus.PROCESSING webhook_request.processing_started_at = timestamp else: - # 5. Create new webhook_request - webhook_request = WebhookRequest( + + + webhook_request_data = WebhookRequestData( payload_hash=payload_hash, webhook_endpoint_id=webhook_endpoint.id, hotel_id=webhook_endpoint.hotel_id, @@ -900,6 +903,9 @@ async def handle_webhook_unified( source_ip=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) + # 5. Create new webhook_request + webhook_request = WebhookRequest(**webhook_request_data.model_dump()) + db_session.add(webhook_request) await db_session.flush() diff --git a/src/alpine_bits_python/schemas.py b/src/alpine_bits_python/schemas.py index 375edc2..0288006 100644 --- a/src/alpine_bits_python/schemas.py +++ b/src/alpine_bits_python/schemas.py @@ -10,11 +10,15 @@ from XML generation (xsdata) follows clean architecture principles. """ import hashlib +import json from datetime import date, datetime from enum import Enum +from typing import Any from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator +from .const import WebhookStatus + # Country name to ISO 3166-1 alpha-2 code mapping COUNTRY_NAME_TO_CODE = { @@ -308,6 +312,148 @@ class CommentsData(BaseModel): model_config = {"from_attributes": True} +class HotelData(BaseModel): + """Validated hotel configuration data.""" + + hotel_id: str = Field(..., min_length=1, max_length=50) + hotel_name: str = Field(..., min_length=1, max_length=200) + username: str = Field(..., min_length=1, max_length=100) + password_hash: str = Field(..., min_length=1, max_length=200) + meta_account_id: str | None = Field(None, max_length=50) + google_account_id: str | None = Field(None, max_length=50) + push_endpoint_url: str | None = Field(None, max_length=500) + push_endpoint_token: str | None = Field(None, max_length=200) + push_endpoint_username: str | None = Field(None, max_length=100) + created_at: datetime = Field(default_factory=lambda: datetime.now()) + updated_at: datetime = Field(default_factory=lambda: datetime.now()) + is_active: bool = Field(default=True) + + @field_validator("hotel_id", "hotel_name", "username") + @classmethod + def strip_whitespace(cls, v: str) -> str: + """Remove leading/trailing whitespace.""" + return v.strip() + + model_config = {"from_attributes": True} + + +class WebhookEndpointData(BaseModel): + """Validated webhook endpoint configuration data.""" + + hotel_id: str = Field(..., min_length=1, max_length=50) + webhook_secret: str = Field(..., min_length=1, max_length=64) + webhook_type: str = Field(..., min_length=1, max_length=50) + description: str | None = Field(None, max_length=200) + is_enabled: bool = Field(default=True) + created_at: datetime = Field(default_factory=lambda: datetime.now()) + + @field_validator("hotel_id", "webhook_secret", "webhook_type") + @classmethod + def strip_whitespace(cls, v: str) -> str: + """Remove leading/trailing whitespace.""" + return v.strip() + + model_config = {"from_attributes": True} + + +class WebhookRequestData(BaseModel): + """Validated webhook request data. + + This model handles the special case where: + - payload_json is required for creation (to calculate payload_hash) + - payload_json becomes optional after processing (can be purged for privacy/storage) + - payload_hash is auto-calculated from payload_json when provided + """ + + # Required fields + payload_json: dict[str, Any] | None = Field( + ..., + description="Webhook payload (required for creation, nullable after purge)" + ) + + # Auto-calculated from payload_json + payload_hash: str | None = Field( + None, + min_length=64, + max_length=64, + description="SHA256 hash of canonical JSON payload (auto-calculated)" + ) + + # Optional foreign keys + webhook_endpoint_id: int | None = Field(None, gt=0) + hotel_id: str | None = Field(None, max_length=50) + + # Processing tracking + status: WebhookStatus = Field(default=WebhookStatus.PENDING) + processing_started_at: datetime | None = None + processing_completed_at: datetime | None = None + + # Retry handling + retry_count: int = Field(default=0, ge=0) + last_error: str | None = Field(None, max_length=2000) + + # Payload metadata + purged_at: datetime | None = None + + # Request metadata + created_at: datetime = Field(default_factory=lambda: datetime.now()) + source_ip: str | None = Field(None, max_length=45) + user_agent: str | None = Field(None, max_length=500) + + # Result tracking + created_customer_id: int | None = Field(None, gt=0) + created_reservation_id: int | None = Field(None, gt=0) + + @model_validator(mode="after") + def calculate_payload_hash(self) -> "WebhookRequestData": + """Auto-calculate payload_hash from payload_json if not provided. + + Uses the same hashing algorithm as api.py: + - Canonical JSON with sorted keys + - UTF-8 encoding + - SHA256 hash + + This runs after all field validation, so we can access the validated payload_json. + """ + # Only calculate if payload_json is provided and payload_hash is not set + if self.payload_json is not None and self.payload_hash is None: + # Create canonical JSON string (sorted keys for consistency) + payload_json_str = json.dumps(self.payload_json, sort_keys=True) + # Calculate SHA256 hash + self.payload_hash = hashlib.sha256( + payload_json_str.encode("utf-8") + ).hexdigest() + + return self + + @model_validator(mode="after") + def validate_payload_hash_requirements(self) -> "WebhookRequestData": + """Ensure payload_hash is present (either provided or calculated). + + This validator runs after calculate_payload_hash, so payload_hash should + be set if payload_json was provided. + """ + if self.payload_hash is None: + raise ValueError( + "payload_hash is required. It can be auto-calculated from payload_json " + "or explicitly provided." + ) + + return self + + @field_validator("status", mode="before") + @classmethod + def normalize_status(cls, v: str | WebhookStatus) -> WebhookStatus: + """Normalize status to WebhookStatus enum.""" + if isinstance(v, WebhookStatus): + return v + if isinstance(v, str): + return WebhookStatus(v) + raise ValueError(f"Invalid webhook status: {v}") + + model_config = {"from_attributes": True} + + # Example usage in a service layer class ReservationService: """Example service showing how to use Pydantic models with SQLAlchemy.""" diff --git a/tests/test_schemas_webhook.py b/tests/test_schemas_webhook.py new file mode 100644 index 0000000..d615f5d --- /dev/null +++ b/tests/test_schemas_webhook.py @@ -0,0 +1,218 @@ +"""Tests for webhook-related Pydantic schemas.""" + +import hashlib +import json +from datetime import datetime + +import pytest +from pydantic import ValidationError + +from alpine_bits_python.const import WebhookStatus +from alpine_bits_python.schemas import ( + HotelData, + WebhookEndpointData, + WebhookRequestData, +) + + +class TestHotelData: + """Tests for HotelData schema.""" + + def test_valid_hotel_data(self): + """Test creating a valid HotelData instance.""" + data = HotelData( + hotel_id="hotel123", + hotel_name="Test Hotel", + username="admin", + password_hash="hashed_password_123", + ) + assert data.hotel_id == "hotel123" + assert data.hotel_name == "Test Hotel" + assert data.username == "admin" + assert data.password_hash == "hashed_password_123" + assert data.is_active is True + assert isinstance(data.created_at, datetime) + + def test_whitespace_stripping(self): + """Test that whitespace is stripped from string fields.""" + data = HotelData( + hotel_id=" hotel123 ", + hotel_name=" Test Hotel ", + username=" admin ", + password_hash="hashed_password_123", + ) + assert data.hotel_id == "hotel123" + assert data.hotel_name == "Test Hotel" + assert data.username == "admin" + + def test_optional_fields(self): + """Test that optional fields can be None.""" + data = HotelData( + hotel_id="hotel123", + hotel_name="Test Hotel", + username="admin", + password_hash="hashed_password_123", + meta_account_id=None, + google_account_id=None, + ) + assert data.meta_account_id is None + assert data.google_account_id is None + + +class TestWebhookEndpointData: + """Tests for WebhookEndpointData schema.""" + + def test_valid_webhook_endpoint(self): + """Test creating a valid WebhookEndpointData instance.""" + data = WebhookEndpointData( + hotel_id="hotel123", + webhook_secret="secret_abc123", + webhook_type="wix_form", + ) + assert data.hotel_id == "hotel123" + assert data.webhook_secret == "secret_abc123" + assert data.webhook_type == "wix_form" + assert data.is_enabled is True + assert isinstance(data.created_at, datetime) + + def test_webhook_endpoint_with_description(self): + """Test WebhookEndpointData with optional description.""" + data = WebhookEndpointData( + hotel_id="hotel123", + webhook_secret="secret_abc123", + webhook_type="generic", + description="Main booking form", + ) + assert data.description == "Main booking form" + + def test_whitespace_stripping(self): + """Test that whitespace is stripped from string fields.""" + data = WebhookEndpointData( + hotel_id=" hotel123 ", + webhook_secret=" secret_abc123 ", + webhook_type=" wix_form ", + ) + assert data.hotel_id == "hotel123" + assert data.webhook_secret == "secret_abc123" + assert data.webhook_type == "wix_form" + + +class TestWebhookRequestData: + """Tests for WebhookRequestData schema.""" + + def test_auto_calculate_payload_hash(self): + """Test that payload_hash is auto-calculated from payload_json.""" + payload = {"name": "John", "email": "john@example.com"} + data = WebhookRequestData(payload_json=payload) + + # Verify hash was calculated + assert data.payload_hash is not None + assert len(data.payload_hash) == 64 # SHA256 produces 64 hex chars + + # Verify it matches the expected hash (same algorithm as api.py) + payload_json_str = json.dumps(payload, sort_keys=True) + expected_hash = hashlib.sha256(payload_json_str.encode("utf-8")).hexdigest() + assert data.payload_hash == expected_hash + + def test_explicit_payload_hash(self): + """Test providing payload_hash explicitly (for purged payloads).""" + explicit_hash = "a" * 64 + data = WebhookRequestData( + payload_json=None, + payload_hash=explicit_hash, + ) + assert data.payload_hash == explicit_hash + assert data.payload_json is None + + def test_payload_hash_required(self): + """Test that payload_hash is required (either calculated or explicit).""" + with pytest.raises(ValidationError) as exc_info: + WebhookRequestData( + payload_json=None, + payload_hash=None, + ) + assert "payload_hash is required" in str(exc_info.value) + + def test_consistent_hashing(self): + """Test that the same payload always produces the same hash.""" + payload = {"b": 2, "a": 1, "c": 3} # Unordered keys + + data1 = WebhookRequestData(payload_json=payload.copy()) + data2 = WebhookRequestData(payload_json=payload.copy()) + + assert data1.payload_hash == data2.payload_hash + + def test_default_status(self): + """Test that status defaults to PENDING.""" + data = WebhookRequestData(payload_json={"test": "data"}) + assert data.status == WebhookStatus.PENDING + + def test_status_normalization(self): + """Test that status is normalized to WebhookStatus enum.""" + data = WebhookRequestData( + payload_json={"test": "data"}, + status="completed", # String + ) + assert data.status == WebhookStatus.COMPLETED + assert isinstance(data.status, WebhookStatus) + + def test_retry_count_default(self): + """Test that retry_count defaults to 0.""" + data = WebhookRequestData(payload_json={"test": "data"}) + assert data.retry_count == 0 + + def test_optional_foreign_keys(self): + """Test optional foreign key fields.""" + data = WebhookRequestData( + payload_json={"test": "data"}, + webhook_endpoint_id=123, + hotel_id="hotel456", + ) + assert data.webhook_endpoint_id == 123 + assert data.hotel_id == "hotel456" + + def test_result_tracking(self): + """Test result tracking fields.""" + data = WebhookRequestData( + payload_json={"test": "data"}, + created_customer_id=1, + created_reservation_id=2, + ) + assert data.created_customer_id == 1 + assert data.created_reservation_id == 2 + + def test_purged_payload(self): + """Test representing a purged webhook request (after processing).""" + explicit_hash = "b" * 64 + data = WebhookRequestData( + payload_json=None, + payload_hash=explicit_hash, + status=WebhookStatus.COMPLETED, + purged_at=datetime.now(), + ) + assert data.payload_json is None + assert data.payload_hash == explicit_hash + assert data.status == WebhookStatus.COMPLETED + assert data.purged_at is not None + + def test_processing_metadata(self): + """Test processing tracking fields.""" + now = datetime.now() + data = WebhookRequestData( + payload_json={"test": "data"}, + status=WebhookStatus.PROCESSING, + processing_started_at=now, + ) + assert data.status == WebhookStatus.PROCESSING + assert data.processing_started_at == now + assert data.processing_completed_at is None + + def test_request_metadata(self): + """Test request metadata fields.""" + data = WebhookRequestData( + payload_json={"test": "data"}, + source_ip="192.168.1.1", + user_agent="Mozilla/5.0", + ) + assert data.source_ip == "192.168.1.1" + assert data.user_agent == "Mozilla/5.0" diff --git a/tests/test_webhook_duplicates.py b/tests/test_webhook_duplicates.py index 25625eb..57580d7 100644 --- a/tests/test_webhook_duplicates.py +++ b/tests/test_webhook_duplicates.py @@ -7,6 +7,7 @@ This module tests: """ import asyncio +import json import uuid from datetime import UTC, datetime from pathlib import Path @@ -22,6 +23,8 @@ from alpine_bits_python.api import app from alpine_bits_python.const import WebhookStatus from alpine_bits_python.db import Base, Reservation, WebhookRequest from alpine_bits_python.db_setup import reprocess_stuck_webhooks +from alpine_bits_python.schemas import WebhookRequestData +from alpine_bits_python.webhook_processor import initialize_webhook_processors, webhook_registry @pytest_asyncio.fixture @@ -165,23 +168,16 @@ class TestWebhookReprocessing: # Step 1: Process a webhook normally to create a reservation from alpine_bits_python.webhook_processor import process_wix_form_submission - test_data = { - "data": { - "submissionId": "STUCK-WEBHOOK-TEST-ID", - "submissionTime": "2025-10-07T05:48:41.855Z", - "contact": { - "name": {"first": "Jane", "last": "Smith"}, - "email": "jane.smith@example.com", - "phones": [{"e164Phone": "+9876543210"}], - "locale": "en-US", - "contactId": "contact-stuck-test", - }, - "field:date_picker_a7c8": "2024-12-25", - "field:date_picker_7e65": "2024-12-31", - "field:number_7cf5": "2", - "field:anzahl_kinder": "0", - } - } + test_form_file = Path(__file__).parent / "test_data" / f"test_form{1}.json" + + if not test_form_file.exists(): + pytest.skip(f"{test_form_file.name} not found") + + # Load test form data + with test_form_file.open() as f: + test_data = json.load(f) + + test_data["data"]["submissionId"] = "STUCK-WEBHOOK-TEST-ID" # Fixed ID for duplicate test async with AsyncSessionLocal() as session: result = await process_wix_form_submission( @@ -197,7 +193,7 @@ class TestWebhookReprocessing: ) result = await session.execute(stmt) reservation = result.scalar_one_or_none() - assert reservation is not None + assert reservation is not None, "Reservation should exist" assert reservation.unique_id == "STUCK-WEBHOOK-TEST-ID" # Step 3: Manually create a webhook request stuck in "processing" status @@ -230,16 +226,23 @@ class TestWebhookReprocessing: await session.flush() # Create stuck webhook request with the SAME payload - stuck_webhook = WebhookRequest( + stuck_webhook_data = WebhookRequestData( webhook_endpoint_id=endpoint.id, hotel_id="HOTEL123", payload_json=test_data, status=WebhookStatus.PROCESSING, # Stuck in processing! created_at=datetime.now(UTC), ) + + stuck_webhook = WebhookRequest(**stuck_webhook_data.model_dump()) session.add(stuck_webhook) await session.commit() + # initialize wix_form processor + + initialize_webhook_processors() + + # Step 4: Run reprocessing (simulates app restart) await reprocess_stuck_webhooks(AsyncSessionLocal, test_config) @@ -307,15 +310,14 @@ class TestWebhookReprocessingNeverBlocksStartup: session.add(endpoint) await session.flush() - # Create stuck webhook with INVALID data (missing required fields) - stuck_webhook = WebhookRequest( - webhook_endpoint_id=endpoint.id, + webhook_request = WebhookRequestData( hotel_id="HOTEL123", payload_json={"data": {"invalid": "data"}}, # Missing required fields - status=WebhookStatus.PROCESSING, - created_at=datetime.now(UTC), - payload_hash="invalidhash" # Add a dummy payload_hash to avoid integrity error + status=WebhookStatus.PROCESSING ) + stuck_webhook = WebhookRequest(**webhook_request.model_dump()) + + session.add(stuck_webhook) ## Cannot add the stuck webhook. Integrity Error payload_hash is missing await session.commit()