Track 1.1 of master plan: expand from sexp-only tests to cover DTOs, HTTP signatures, HMAC auth, URL utilities, Jinja filters, calendar helpers, config freeze, activity bus registry, parse utilities, sexp helpers, error classes, and jinja bridge render API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
7.5 KiB
Python
216 lines
7.5 KiB
Python
"""Tests for DTO serialization helpers and dataclass contracts."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from shared.contracts.dtos import (
|
|
PostDTO,
|
|
CalendarDTO,
|
|
CalendarEntryDTO,
|
|
TicketDTO,
|
|
MarketPlaceDTO,
|
|
ProductDTO,
|
|
CartItemDTO,
|
|
CartSummaryDTO,
|
|
ActorProfileDTO,
|
|
APActivityDTO,
|
|
RemotePostDTO,
|
|
dto_to_dict,
|
|
dto_from_dict,
|
|
_serialize_value,
|
|
_unwrap_optional,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _serialize_value
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSerializeValue:
|
|
def test_datetime(self):
|
|
dt = datetime(2025, 6, 15, 12, 30, 0)
|
|
assert _serialize_value(dt) == "2025-06-15T12:30:00"
|
|
|
|
def test_decimal(self):
|
|
assert _serialize_value(Decimal("19.99")) == "19.99"
|
|
|
|
def test_set_to_list(self):
|
|
result = _serialize_value({1, 2, 3})
|
|
assert isinstance(result, list)
|
|
assert set(result) == {1, 2, 3}
|
|
|
|
def test_string_passthrough(self):
|
|
assert _serialize_value("hello") == "hello"
|
|
|
|
def test_int_passthrough(self):
|
|
assert _serialize_value(42) == 42
|
|
|
|
def test_none_passthrough(self):
|
|
assert _serialize_value(None) is None
|
|
|
|
def test_nested_list(self):
|
|
dt = datetime(2025, 1, 1)
|
|
result = _serialize_value([dt, Decimal("5")])
|
|
assert result == ["2025-01-01T00:00:00", "5"]
|
|
|
|
def test_nested_dataclass(self):
|
|
post = PostDTO(id=1, slug="test", title="Test", status="published", visibility="public")
|
|
result = _serialize_value(post)
|
|
assert isinstance(result, dict)
|
|
assert result["slug"] == "test"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _unwrap_optional
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestUnwrapOptional:
|
|
def test_optional_str(self):
|
|
from typing import Optional
|
|
result = _unwrap_optional(Optional[str])
|
|
assert result is str
|
|
|
|
def test_optional_int(self):
|
|
from typing import Optional
|
|
result = _unwrap_optional(Optional[int])
|
|
assert result is int
|
|
|
|
def test_plain_type(self):
|
|
assert _unwrap_optional(str) is str
|
|
|
|
def test_union_with_none(self):
|
|
from typing import Union
|
|
result = _unwrap_optional(Union[datetime, None])
|
|
assert result is datetime
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dto_to_dict
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDtoToDict:
|
|
def test_simple_post(self):
|
|
post = PostDTO(id=1, slug="my-post", title="My Post", status="published", visibility="public")
|
|
d = dto_to_dict(post)
|
|
assert d["id"] == 1
|
|
assert d["slug"] == "my-post"
|
|
assert d["feature_image"] is None
|
|
|
|
def test_with_datetime(self):
|
|
dt = datetime(2025, 6, 15, 10, 0, 0)
|
|
post = PostDTO(id=1, slug="s", title="T", status="published", visibility="public", published_at=dt)
|
|
d = dto_to_dict(post)
|
|
assert d["published_at"] == "2025-06-15T10:00:00"
|
|
|
|
def test_with_decimal(self):
|
|
product = ProductDTO(id=1, slug="widget", rrp=Decimal("29.99"), regular_price=Decimal("24.99"))
|
|
d = dto_to_dict(product)
|
|
assert d["rrp"] == "29.99"
|
|
assert d["regular_price"] == "24.99"
|
|
|
|
def test_cart_summary_with_items(self):
|
|
item = CartItemDTO(id=1, product_id=10, quantity=2, unit_price=Decimal("5.00"))
|
|
summary = CartSummaryDTO(count=1, total=Decimal("10.00"), items=[item])
|
|
d = dto_to_dict(summary)
|
|
assert d["count"] == 1
|
|
assert d["total"] == "10.00"
|
|
assert len(d["items"]) == 1
|
|
assert d["items"][0]["product_id"] == 10
|
|
|
|
def test_remote_post_with_nested_lists(self):
|
|
rp = RemotePostDTO(
|
|
id=1, remote_actor_id=5, object_id="https://example.com/1",
|
|
content="<p>Hello</p>",
|
|
attachments=[{"type": "Image", "url": "https://img.example.com/1.jpg"}],
|
|
tags=[{"type": "Hashtag", "name": "#test"}],
|
|
)
|
|
d = dto_to_dict(rp)
|
|
assert d["attachments"] == [{"type": "Image", "url": "https://img.example.com/1.jpg"}]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dto_from_dict
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDtoFromDict:
|
|
def test_none_data(self):
|
|
assert dto_from_dict(PostDTO, None) is None
|
|
|
|
def test_empty_dict(self):
|
|
assert dto_from_dict(PostDTO, {}) is None
|
|
|
|
def test_simple_post(self):
|
|
d = {"id": 1, "slug": "test", "title": "Test", "status": "published", "visibility": "public"}
|
|
post = dto_from_dict(PostDTO, d)
|
|
assert post.slug == "test"
|
|
assert post.feature_image is None
|
|
|
|
def test_datetime_coercion(self):
|
|
d = {
|
|
"id": 1, "slug": "s", "title": "T", "status": "published",
|
|
"visibility": "public", "published_at": "2025-06-15T10:00:00",
|
|
}
|
|
post = dto_from_dict(PostDTO, d)
|
|
assert isinstance(post.published_at, datetime)
|
|
assert post.published_at.year == 2025
|
|
|
|
def test_decimal_coercion(self):
|
|
d = {"id": 1, "slug": "w", "rrp": "29.99", "regular_price": 24.99}
|
|
product = dto_from_dict(ProductDTO, d)
|
|
assert isinstance(product.rrp, Decimal)
|
|
assert product.rrp == Decimal("29.99")
|
|
assert isinstance(product.regular_price, Decimal)
|
|
|
|
def test_round_trip(self):
|
|
dt = datetime(2025, 3, 1, 9, 30, 0)
|
|
original = PostDTO(id=1, slug="rt", title="Round Trip", status="draft", visibility="members", published_at=dt)
|
|
d = dto_to_dict(original)
|
|
restored = dto_from_dict(PostDTO, d)
|
|
assert restored.id == original.id
|
|
assert restored.slug == original.slug
|
|
assert restored.published_at == original.published_at
|
|
|
|
def test_extra_keys_ignored(self):
|
|
d = {"id": 1, "slug": "s", "title": "T", "status": "published", "visibility": "public", "extra_field": "ignored"}
|
|
post = dto_from_dict(PostDTO, d)
|
|
assert post.slug == "s"
|
|
|
|
def test_calendar_entry_decimals(self):
|
|
d = {
|
|
"id": 1, "calendar_id": 2, "name": "Event", "start_at": "2025-07-01T14:00:00",
|
|
"state": "confirmed", "cost": "15.00", "ticket_price": "10.50",
|
|
}
|
|
entry = dto_from_dict(CalendarEntryDTO, d)
|
|
assert isinstance(entry.cost, Decimal)
|
|
assert entry.cost == Decimal("15.00")
|
|
assert isinstance(entry.ticket_price, Decimal)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frozen DTOs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFrozenDTOs:
|
|
def test_post_is_frozen(self):
|
|
post = PostDTO(id=1, slug="s", title="T", status="published", visibility="public")
|
|
with pytest.raises(AttributeError):
|
|
post.title = "changed"
|
|
|
|
def test_product_is_frozen(self):
|
|
product = ProductDTO(id=1, slug="s")
|
|
with pytest.raises(AttributeError):
|
|
product.slug = "changed"
|
|
|
|
def test_calendar_dto_defaults(self):
|
|
cal = CalendarDTO(id=1, container_type="page", container_id=5, name="My Cal", slug="my-cal")
|
|
assert cal.description is None
|
|
|
|
def test_cart_summary_defaults(self):
|
|
summary = CartSummaryDTO()
|
|
assert summary.count == 0
|
|
assert summary.total == Decimal("0")
|
|
assert summary.items == []
|
|
assert summary.ticket_count == 0
|