concurrency-fix #15
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
218
tests/test_schemas_webhook.py
Normal file
218
tests/test_schemas_webhook.py
Normal 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"
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user