diff --git a/shared/tests/__init__.py b/shared/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/tests/conftest.py b/shared/tests/conftest.py new file mode 100644 index 0000000..1532379 --- /dev/null +++ b/shared/tests/conftest.py @@ -0,0 +1,10 @@ +"""Shared test fixtures for unit tests.""" +from __future__ import annotations + +import os +import sys + +# Ensure project root is on sys.path so shared.* imports work +_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) diff --git a/shared/tests/test_activity_bus.py b/shared/tests/test_activity_bus.py new file mode 100644 index 0000000..013daa4 --- /dev/null +++ b/shared/tests/test_activity_bus.py @@ -0,0 +1,119 @@ +"""Tests for activity bus handler registry.""" +from __future__ import annotations + +import pytest +from collections import defaultdict + +from shared.events.bus import ( + register_activity_handler, + get_activity_handlers, + _activity_handlers, +) + + +@pytest.fixture(autouse=True) +def _clean_handlers(): + """Clear handler registry before each test.""" + _activity_handlers.clear() + yield + _activity_handlers.clear() + + +# Dummy handlers +async def handler_a(activity, session): + pass + +async def handler_b(activity, session): + pass + +async def handler_c(activity, session): + pass + +async def handler_global(activity, session): + pass + + +# --------------------------------------------------------------------------- +# register_activity_handler +# --------------------------------------------------------------------------- + +class TestRegisterHandler: + def test_register_with_type_only(self): + register_activity_handler("Create", handler_a) + assert ("Create", "*") in _activity_handlers + assert handler_a in _activity_handlers[("Create", "*")] + + def test_register_with_object_type(self): + register_activity_handler("Create", handler_a, object_type="Note") + assert ("Create", "Note") in _activity_handlers + + def test_multiple_handlers_same_key(self): + register_activity_handler("Create", handler_a) + register_activity_handler("Create", handler_b) + assert len(_activity_handlers[("Create", "*")]) == 2 + + def test_wildcard_type(self): + register_activity_handler("*", handler_global) + assert ("*", "*") in _activity_handlers + + +# --------------------------------------------------------------------------- +# get_activity_handlers — cascading wildcard lookup +# --------------------------------------------------------------------------- + +class TestGetHandlers: + def test_exact_match(self): + register_activity_handler("Create", handler_a, object_type="Note") + handlers = get_activity_handlers("Create", "Note") + assert handler_a in handlers + + def test_type_wildcard_match(self): + register_activity_handler("Create", handler_a) # key: ("Create", "*") + handlers = get_activity_handlers("Create", "Note") + assert handler_a in handlers + + def test_global_wildcard_match(self): + register_activity_handler("*", handler_global) # key: ("*", "*") + handlers = get_activity_handlers("Create", "Note") + assert handler_global in handlers + + def test_cascading_order(self): + """Handlers should come in order: exact → type-wildcard → global-wildcard.""" + register_activity_handler("Create", handler_a, object_type="Note") # exact + register_activity_handler("Create", handler_b) # type-wildcard + register_activity_handler("*", handler_c) # global wildcard + + handlers = get_activity_handlers("Create", "Note") + assert handlers == [handler_a, handler_b, handler_c] + + def test_no_match(self): + register_activity_handler("Create", handler_a, object_type="Note") + handlers = get_activity_handlers("Delete", "Article") + assert handlers == [] + + def test_no_object_type_skips_exact(self): + register_activity_handler("Create", handler_a, object_type="Note") + register_activity_handler("Create", handler_b) + handlers = get_activity_handlers("Create") + # Should get type-wildcard only (since object_type defaults to "*") + assert handler_b in handlers + assert handler_a not in handlers + + def test_global_wildcard_not_duplicated(self): + """Global wildcard should not fire when activity_type is already '*'.""" + register_activity_handler("*", handler_global) + handlers = get_activity_handlers("*") + # Should not include global wildcard twice + assert handlers.count(handler_global) == 1 + + def test_type_wildcard_plus_global(self): + register_activity_handler("Follow", handler_a) + register_activity_handler("*", handler_global) + handlers = get_activity_handlers("Follow") + assert handler_a in handlers + assert handler_global in handlers + + def test_only_global_wildcard(self): + register_activity_handler("*", handler_global) + handlers = get_activity_handlers("Like", "Post") + assert handlers == [handler_global] diff --git a/shared/tests/test_calendar_helpers.py b/shared/tests/test_calendar_helpers.py new file mode 100644 index 0000000..20c52ef --- /dev/null +++ b/shared/tests/test_calendar_helpers.py @@ -0,0 +1,117 @@ +"""Tests for calendar date helper functions.""" +from __future__ import annotations + +from datetime import date +from unittest.mock import patch, MagicMock + +from shared.utils.calendar_helpers import add_months, build_calendar_weeks + + +# --------------------------------------------------------------------------- +# add_months +# --------------------------------------------------------------------------- + +class TestAddMonths: + def test_same_year(self): + assert add_months(2025, 3, 2) == (2025, 5) + + def test_next_year(self): + assert add_months(2025, 11, 2) == (2026, 1) + + def test_subtract(self): + assert add_months(2025, 3, -2) == (2025, 1) + + def test_subtract_prev_year(self): + assert add_months(2025, 1, -1) == (2024, 12) + + def test_add_twelve(self): + assert add_months(2025, 6, 12) == (2026, 6) + + def test_subtract_twelve(self): + assert add_months(2025, 6, -12) == (2024, 6) + + def test_large_delta(self): + assert add_months(2025, 1, 25) == (2027, 2) + + def test_zero_delta(self): + assert add_months(2025, 7, 0) == (2025, 7) + + def test_december_to_january(self): + assert add_months(2025, 12, 1) == (2026, 1) + + def test_january_to_december(self): + assert add_months(2025, 1, -1) == (2024, 12) + + def test_subtract_large(self): + assert add_months(2025, 3, -15) == (2023, 12) + + def test_month_boundaries(self): + # Every month +1 + for m in range(1, 12): + y, nm = add_months(2025, m, 1) + assert nm == m + 1 + assert y == 2025 + y, nm = add_months(2025, 12, 1) + assert nm == 1 + assert y == 2026 + + +# --------------------------------------------------------------------------- +# build_calendar_weeks +# --------------------------------------------------------------------------- + +class TestBuildCalendarWeeks: + def test_returns_list_of_weeks(self): + weeks = build_calendar_weeks(2025, 6) + assert isinstance(weeks, list) + assert len(weeks) >= 4 # at least 4 weeks in any month + assert len(weeks) <= 6 # at most 6 weeks + + def test_each_week_has_7_days(self): + weeks = build_calendar_weeks(2025, 6) + for week in weeks: + assert len(week) == 7 + + def test_day_dict_structure(self): + weeks = build_calendar_weeks(2025, 6) + day = weeks[0][0] + assert "date" in day + assert "in_month" in day + assert "is_today" in day + assert isinstance(day["date"], date) + assert isinstance(day["in_month"], bool) + assert isinstance(day["is_today"], bool) + + def test_in_month_flag(self): + weeks = build_calendar_weeks(2025, 6) + june_days = [d for w in weeks for d in w if d["in_month"]] + assert len(june_days) == 30 # June has 30 days + + def test_february_leap_year(self): + weeks = build_calendar_weeks(2024, 2) + feb_days = [d for w in weeks for d in w if d["in_month"]] + assert len(feb_days) == 29 + + def test_february_non_leap_year(self): + weeks = build_calendar_weeks(2025, 2) + feb_days = [d for w in weeks for d in w if d["in_month"]] + assert len(feb_days) == 28 + + def test_starts_on_monday(self): + """Calendar should start on Monday (firstweekday=0).""" + weeks = build_calendar_weeks(2025, 6) + first_day = weeks[0][0]["date"] + assert first_day.weekday() == 0 # Monday + + def test_is_today_flag(self): + """The today flag should be True for exactly one day (or zero if not in month range).""" + # Use a fixed known date - mock datetime.now + from datetime import datetime, timezone + fixed_now = datetime(2025, 6, 15, tzinfo=timezone.utc) + with patch("shared.utils.calendar_helpers.datetime") as mock_dt: + mock_dt.now.return_value = fixed_now + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + weeks = build_calendar_weeks(2025, 6) + today_days = [d for w in weeks for d in w if d["is_today"]] + assert len(today_days) == 1 + assert today_days[0]["date"] == date(2025, 6, 15) diff --git a/shared/tests/test_config.py b/shared/tests/test_config.py new file mode 100644 index 0000000..71bf146 --- /dev/null +++ b/shared/tests/test_config.py @@ -0,0 +1,152 @@ +"""Tests for config freeze/readonly enforcement.""" +from __future__ import annotations + +import asyncio +import os +import tempfile +from types import MappingProxyType + +import pytest + +from shared.config import _freeze + + +# --------------------------------------------------------------------------- +# _freeze +# --------------------------------------------------------------------------- + +class TestFreeze: + def test_freezes_dict(self): + result = _freeze({"a": 1, "b": 2}) + assert isinstance(result, MappingProxyType) + assert result["a"] == 1 + + def test_frozen_dict_is_immutable(self): + result = _freeze({"a": 1}) + with pytest.raises(TypeError): + result["a"] = 2 + with pytest.raises(TypeError): + result["new"] = 3 + + def test_freezes_list_to_tuple(self): + result = _freeze([1, 2, 3]) + assert isinstance(result, tuple) + assert result == (1, 2, 3) + + def test_freezes_set_to_frozenset(self): + result = _freeze({1, 2, 3}) + assert isinstance(result, frozenset) + assert result == frozenset({1, 2, 3}) + + def test_freezes_nested_dict(self): + result = _freeze({"a": {"b": {"c": 1}}}) + assert isinstance(result, MappingProxyType) + assert isinstance(result["a"], MappingProxyType) + assert isinstance(result["a"]["b"], MappingProxyType) + assert result["a"]["b"]["c"] == 1 + + def test_freezes_dict_with_list(self): + result = _freeze({"items": [1, 2, 3]}) + assert isinstance(result["items"], tuple) + + def test_freezes_list_of_dicts(self): + result = _freeze([{"a": 1}, {"b": 2}]) + assert isinstance(result, tuple) + assert isinstance(result[0], MappingProxyType) + + def test_preserves_scalars(self): + assert _freeze(42) == 42 + assert _freeze("hello") == "hello" + assert _freeze(3.14) == 3.14 + assert _freeze(True) is True + assert _freeze(None) is None + + def test_freezes_tuple_recursively(self): + result = _freeze(({"a": 1}, [2, 3])) + assert isinstance(result, tuple) + assert isinstance(result[0], MappingProxyType) + assert isinstance(result[1], tuple) + + def test_complex_config_structure(self): + """Simulates a real app-config.yaml structure.""" + raw = { + "app_urls": { + "blog": "https://blog.rose-ash.com", + "market": "https://market.rose-ash.com", + }, + "features": ["sexp", "federation"], + "limits": {"max_upload": 10485760}, + } + frozen = _freeze(raw) + assert frozen["app_urls"]["blog"] == "https://blog.rose-ash.com" + assert frozen["features"] == ("sexp", "federation") + with pytest.raises(TypeError): + frozen["app_urls"]["blog"] = "changed" + + +# --------------------------------------------------------------------------- +# init_config / config / as_plain / pretty +# --------------------------------------------------------------------------- + +class TestConfigInit: + def test_init_and_read(self): + """Test full init_config → config() → as_plain() → pretty() cycle.""" + import shared.config as cfg + + # Save original state + orig_frozen = cfg._data_frozen + orig_plain = cfg._data_plain + + try: + # Reset state + cfg._data_frozen = None + cfg._data_plain = None + + # Write a temp YAML file + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("app_urls:\n blog: https://blog.example.com\nport: 8001\n") + path = f.name + + try: + asyncio.run(cfg.init_config(path, force=True)) + + c = cfg.config() + assert c["app_urls"]["blog"] == "https://blog.example.com" + assert c["port"] == 8001 + assert isinstance(c, MappingProxyType) + + plain = cfg.as_plain() + assert isinstance(plain, dict) + assert plain["port"] == 8001 + # Modifying plain should not affect config + plain["port"] = 9999 + assert cfg.config()["port"] == 8001 + + pretty_str = cfg.pretty() + assert "blog" in pretty_str + finally: + os.unlink(path) + finally: + # Restore original state + cfg._data_frozen = orig_frozen + cfg._data_plain = orig_plain + + def test_config_raises_before_init(self): + import shared.config as cfg + orig = cfg._data_frozen + try: + cfg._data_frozen = None + with pytest.raises(RuntimeError, match="init_config"): + cfg.config() + finally: + cfg._data_frozen = orig + + def test_file_not_found(self): + import shared.config as cfg + orig = cfg._data_frozen + try: + cfg._data_frozen = None + with pytest.raises(FileNotFoundError): + asyncio.run(cfg.init_config("/nonexistent/path.yaml", force=True)) + finally: + cfg._data_frozen = orig diff --git a/shared/tests/test_dtos.py b/shared/tests/test_dtos.py new file mode 100644 index 0000000..663da56 --- /dev/null +++ b/shared/tests/test_dtos.py @@ -0,0 +1,215 @@ +"""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="

Hello

", + 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 diff --git a/shared/tests/test_errors.py b/shared/tests/test_errors.py new file mode 100644 index 0000000..4a10757 --- /dev/null +++ b/shared/tests/test_errors.py @@ -0,0 +1,96 @@ +"""Tests for error classes and error page rendering.""" +from __future__ import annotations + +import pytest + +from shared.browser.app.errors import AppError, _error_page + + +# --------------------------------------------------------------------------- +# AppError +# --------------------------------------------------------------------------- + +class TestAppError: + def test_single_message(self): + err = AppError("Something went wrong") + assert str(err) == "Something went wrong" + assert err.messages == ["Something went wrong"] + assert err.status_code == 400 + + def test_custom_status_code(self): + err = AppError("Not found", status_code=404) + assert err.status_code == 404 + assert str(err) == "Not found" + + def test_list_of_messages(self): + err = AppError(["Error 1", "Error 2", "Error 3"]) + assert err.messages == ["Error 1", "Error 2", "Error 3"] + assert str(err) == "Error 1" # first message as str + + def test_tuple_of_messages(self): + err = AppError(("A", "B")) + assert err.messages == ["A", "B"] + + def test_set_of_messages(self): + err = AppError({"only one"}) + assert err.messages == ["only one"] + + def test_empty_list(self): + err = AppError([]) + assert err.messages == [] + assert str(err) == "" + + def test_is_value_error(self): + """AppError should be catchable as ValueError for backwards compat.""" + err = AppError("test") + assert isinstance(err, ValueError) + + def test_default_status_is_400(self): + err = AppError("test") + assert err.status_code == 400 + + def test_integer_message_coerced(self): + err = AppError([42, "text"]) + assert err.messages == ["42", "text"] + + def test_status_code_override(self): + err = AppError("conflict", status_code=409) + assert err.status_code == 409 + + def test_messages_are_strings(self): + err = AppError([None, 123, True]) + assert all(isinstance(m, str) for m in err.messages) + + +# --------------------------------------------------------------------------- +# _error_page +# --------------------------------------------------------------------------- + +class TestErrorPage: + def test_returns_html_string(self): + html = _error_page("Not Found") + assert isinstance(html, str) + assert "" in html + + def test_contains_message(self): + html = _error_page("Something broke") + assert "Something broke" in html + + def test_contains_error_gif(self): + html = _error_page("Error") + assert "/static/errors/error.gif" in html + + def test_contains_reload_link(self): + html = _error_page("Error") + assert "Reload" in html + + def test_html_in_message(self): + """Messages can contain HTML (used by fragment_error handler).""" + html = _error_page("The account service is unavailable") + assert "account" in html + + def test_self_contained(self): + """Error page should include its own styles (no external CSS deps).""" + html = _error_page("Error") + assert "" in html diff --git a/shared/tests/test_filters.py b/shared/tests/test_filters.py new file mode 100644 index 0000000..ef87d92 --- /dev/null +++ b/shared/tests/test_filters.py @@ -0,0 +1,291 @@ +"""Tests for Jinja template filters (pure-logic functions).""" +from __future__ import annotations + +from decimal import Decimal + +from markupsafe import Markup + +from shared.browser.app.filters.highlight import highlight +from shared.browser.app.filters.combine import _deep_merge +from shared.browser.app.filters.qs_base import ( + _iterify, + _norm, + make_filter_set, + build_qs, +) + + +# --------------------------------------------------------------------------- +# highlight +# --------------------------------------------------------------------------- + +class TestHighlight: + def test_basic_highlight(self): + result = highlight("Hello World", "world") + assert isinstance(result, Markup) + assert "alert('xss')", "script") + assert "') + assert "