Fixed some tests and added schemas

This commit is contained in:
Jonas Linter
2025-12-01 10:14:14 +01:00
parent 3e577a499f
commit 2be10ff899
4 changed files with 399 additions and 27 deletions

View File

@@ -32,6 +32,8 @@ from sqlalchemy import and_, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from alpine_bits_python.schemas import WebhookRequestData
from .alpinebits_server import ( from .alpinebits_server import (
AlpineBitsActionName, AlpineBitsActionName,
AlpineBitsClientInfo, AlpineBitsClientInfo,
@@ -888,8 +890,9 @@ async def handle_webhook_unified(
webhook_request.status = WebhookStatus.PROCESSING webhook_request.status = WebhookStatus.PROCESSING
webhook_request.processing_started_at = timestamp webhook_request.processing_started_at = timestamp
else: else:
# 5. Create new webhook_request
webhook_request = WebhookRequest(
webhook_request_data = WebhookRequestData(
payload_hash=payload_hash, payload_hash=payload_hash,
webhook_endpoint_id=webhook_endpoint.id, webhook_endpoint_id=webhook_endpoint.id,
hotel_id=webhook_endpoint.hotel_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, source_ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"), 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) db_session.add(webhook_request)
await db_session.flush() await db_session.flush()

View File

@@ -10,11 +10,15 @@ from XML generation (xsdata) follows clean architecture principles.
""" """
import hashlib import hashlib
import json
from datetime import date, datetime from datetime import date, datetime
from enum import Enum from enum import Enum
from typing import Any
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator 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 ISO 3166-1 alpha-2 code mapping
COUNTRY_NAME_TO_CODE = { COUNTRY_NAME_TO_CODE = {
@@ -308,6 +312,148 @@ class CommentsData(BaseModel):
model_config = {"from_attributes": True} 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 # Example usage in a service layer
class ReservationService: class ReservationService:
"""Example service showing how to use Pydantic models with SQLAlchemy.""" """Example service showing how to use Pydantic models with SQLAlchemy."""

View File

@@ -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"

View File

@@ -7,6 +7,7 @@ This module tests:
""" """
import asyncio import asyncio
import json
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path 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.const import WebhookStatus
from alpine_bits_python.db import Base, Reservation, WebhookRequest from alpine_bits_python.db import Base, Reservation, WebhookRequest
from alpine_bits_python.db_setup import reprocess_stuck_webhooks 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 @pytest_asyncio.fixture
@@ -165,23 +168,16 @@ class TestWebhookReprocessing:
# Step 1: Process a webhook normally to create a reservation # Step 1: Process a webhook normally to create a reservation
from alpine_bits_python.webhook_processor import process_wix_form_submission from alpine_bits_python.webhook_processor import process_wix_form_submission
test_data = { test_form_file = Path(__file__).parent / "test_data" / f"test_form{1}.json"
"data": {
"submissionId": "STUCK-WEBHOOK-TEST-ID", if not test_form_file.exists():
"submissionTime": "2025-10-07T05:48:41.855Z", pytest.skip(f"{test_form_file.name} not found")
"contact": {
"name": {"first": "Jane", "last": "Smith"}, # Load test form data
"email": "jane.smith@example.com", with test_form_file.open() as f:
"phones": [{"e164Phone": "+9876543210"}], test_data = json.load(f)
"locale": "en-US",
"contactId": "contact-stuck-test", test_data["data"]["submissionId"] = "STUCK-WEBHOOK-TEST-ID" # Fixed ID for duplicate test
},
"field:date_picker_a7c8": "2024-12-25",
"field:date_picker_7e65": "2024-12-31",
"field:number_7cf5": "2",
"field:anzahl_kinder": "0",
}
}
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await process_wix_form_submission( result = await process_wix_form_submission(
@@ -197,7 +193,7 @@ class TestWebhookReprocessing:
) )
result = await session.execute(stmt) result = await session.execute(stmt)
reservation = result.scalar_one_or_none() 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" assert reservation.unique_id == "STUCK-WEBHOOK-TEST-ID"
# Step 3: Manually create a webhook request stuck in "processing" status # Step 3: Manually create a webhook request stuck in "processing" status
@@ -230,16 +226,23 @@ class TestWebhookReprocessing:
await session.flush() await session.flush()
# Create stuck webhook request with the SAME payload # Create stuck webhook request with the SAME payload
stuck_webhook = WebhookRequest( stuck_webhook_data = WebhookRequestData(
webhook_endpoint_id=endpoint.id, webhook_endpoint_id=endpoint.id,
hotel_id="HOTEL123", hotel_id="HOTEL123",
payload_json=test_data, payload_json=test_data,
status=WebhookStatus.PROCESSING, # Stuck in processing! status=WebhookStatus.PROCESSING, # Stuck in processing!
created_at=datetime.now(UTC), created_at=datetime.now(UTC),
) )
stuck_webhook = WebhookRequest(**stuck_webhook_data.model_dump())
session.add(stuck_webhook) session.add(stuck_webhook)
await session.commit() await session.commit()
# initialize wix_form processor
initialize_webhook_processors()
# Step 4: Run reprocessing (simulates app restart) # Step 4: Run reprocessing (simulates app restart)
await reprocess_stuck_webhooks(AsyncSessionLocal, test_config) await reprocess_stuck_webhooks(AsyncSessionLocal, test_config)
@@ -307,15 +310,14 @@ class TestWebhookReprocessingNeverBlocksStartup:
session.add(endpoint) session.add(endpoint)
await session.flush() await session.flush()
# Create stuck webhook with INVALID data (missing required fields) webhook_request = WebhookRequestData(
stuck_webhook = WebhookRequest(
webhook_endpoint_id=endpoint.id,
hotel_id="HOTEL123", hotel_id="HOTEL123",
payload_json={"data": {"invalid": "data"}}, # Missing required fields payload_json={"data": {"invalid": "data"}}, # Missing required fields
status=WebhookStatus.PROCESSING, status=WebhookStatus.PROCESSING
created_at=datetime.now(UTC),
payload_hash="invalidhash" # Add a dummy payload_hash to avoid integrity error
) )
stuck_webhook = WebhookRequest(**webhook_request.model_dump())
session.add(stuck_webhook) ## Cannot add the stuck webhook. Integrity Error payload_hash is missing session.add(stuck_webhook) ## Cannot add the stuck webhook. Integrity Error payload_hash is missing
await session.commit() await session.commit()