diff --git a/account/tests/__init__.py b/account/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/tests/test_auth_operations.py b/account/tests/test_auth_operations.py new file mode 100644 index 0000000..91e7f5c --- /dev/null +++ b/account/tests/test_auth_operations.py @@ -0,0 +1,39 @@ +"""Unit tests for account auth operations.""" +from __future__ import annotations + +import pytest + +from account.bp.auth.services.auth_operations import validate_email + + +class TestValidateEmail: + def test_valid_email(self): + ok, email = validate_email("user@example.com") + assert ok is True + assert email == "user@example.com" + + def test_uppercase_lowered(self): + ok, email = validate_email("USER@EXAMPLE.COM") + assert ok is True + assert email == "user@example.com" + + def test_whitespace_stripped(self): + ok, email = validate_email(" user@example.com ") + assert ok is True + assert email == "user@example.com" + + def test_empty_string(self): + ok, email = validate_email("") + assert ok is False + + def test_no_at_sign(self): + ok, email = validate_email("notanemail") + assert ok is False + + def test_just_at(self): + ok, email = validate_email("@") + assert ok is True # has "@", passes the basic check + + def test_spaces_only(self): + ok, email = validate_email(" ") + assert ok is False diff --git a/account/tests/test_ghost_membership.py b/account/tests/test_ghost_membership.py new file mode 100644 index 0000000..432be7a --- /dev/null +++ b/account/tests/test_ghost_membership.py @@ -0,0 +1,164 @@ +"""Unit tests for Ghost membership helpers.""" +from __future__ import annotations + +from datetime import datetime + +import pytest + +from account.services.ghost_membership import ( + _iso, _to_str_or_none, _member_email, + _price_cents, _sanitize_member_payload, +) + + +class TestIso: + def test_none(self): + assert _iso(None) is None + + def test_empty(self): + assert _iso("") is None + + def test_z_suffix(self): + result = _iso("2024-06-15T12:00:00Z") + assert isinstance(result, datetime) + assert result.year == 2024 + + def test_offset(self): + result = _iso("2024-06-15T12:00:00+00:00") + assert isinstance(result, datetime) + + +class TestToStrOrNone: + def test_none(self): + assert _to_str_or_none(None) is None + + def test_dict(self): + assert _to_str_or_none({"a": 1}) is None + + def test_list(self): + assert _to_str_or_none([1, 2]) is None + + def test_bytes(self): + assert _to_str_or_none(b"hello") is None + + def test_empty_string(self): + assert _to_str_or_none("") is None + + def test_whitespace_only(self): + assert _to_str_or_none(" ") is None + + def test_valid_string(self): + assert _to_str_or_none("hello") == "hello" + + def test_int(self): + assert _to_str_or_none(42) == "42" + + def test_strips_whitespace(self): + assert _to_str_or_none(" hi ") == "hi" + + def test_set(self): + assert _to_str_or_none({1, 2}) is None + + def test_tuple(self): + assert _to_str_or_none((1,)) is None + + def test_bytearray(self): + assert _to_str_or_none(bytearray(b"x")) is None + + +class TestMemberEmail: + def test_normal(self): + assert _member_email({"email": "USER@EXAMPLE.COM"}) == "user@example.com" + + def test_none(self): + assert _member_email({"email": None}) is None + + def test_empty(self): + assert _member_email({"email": ""}) is None + + def test_whitespace(self): + assert _member_email({"email": " "}) is None + + def test_missing_key(self): + assert _member_email({}) is None + + def test_strips(self): + assert _member_email({"email": " a@b.com "}) == "a@b.com" + + +class TestPriceCents: + def test_valid(self): + assert _price_cents({"price": {"amount": 1500}}) == 1500 + + def test_string_amount(self): + assert _price_cents({"price": {"amount": "2000"}}) == 2000 + + def test_missing_price(self): + assert _price_cents({}) is None + + def test_missing_amount(self): + assert _price_cents({"price": {}}) is None + + def test_none_amount(self): + assert _price_cents({"price": {"amount": None}}) is None + + def test_nested_none(self): + assert _price_cents({"price": None}) is None + + +class TestSanitizeMemberPayload: + def test_email_lowercased(self): + result = _sanitize_member_payload({"email": "USER@EXAMPLE.COM"}) + assert result["email"] == "user@example.com" + + def test_empty_email_excluded(self): + result = _sanitize_member_payload({"email": ""}) + assert "email" not in result + + def test_name_included(self): + result = _sanitize_member_payload({"name": "Alice"}) + assert result["name"] == "Alice" + + def test_note_included(self): + result = _sanitize_member_payload({"note": "VIP"}) + assert result["note"] == "VIP" + + def test_subscribed_bool(self): + result = _sanitize_member_payload({"subscribed": 1}) + assert result["subscribed"] is True + + def test_labels_with_id(self): + result = _sanitize_member_payload({ + "labels": [{"id": "abc"}, {"name": "VIP"}] + }) + assert result["labels"] == [{"id": "abc"}, {"name": "VIP"}] + + def test_labels_empty_items_excluded(self): + result = _sanitize_member_payload({ + "labels": [{"id": None, "name": None}] + }) + assert "labels" not in result + + def test_newsletters_with_id(self): + result = _sanitize_member_payload({ + "newsletters": [{"id": "n1", "subscribed": True}] + }) + assert result["newsletters"] == [{"subscribed": True, "id": "n1"}] + + def test_newsletters_default_subscribed(self): + result = _sanitize_member_payload({ + "newsletters": [{"name": "Weekly"}] + }) + assert result["newsletters"][0]["subscribed"] is True + + def test_dict_email_excluded(self): + result = _sanitize_member_payload({"email": {"bad": "input"}}) + assert "email" not in result + + def test_id_passthrough(self): + result = _sanitize_member_payload({"id": "ghost-member-123"}) + assert result["id"] == "ghost-member-123" + + def test_empty_payload(self): + result = _sanitize_member_payload({}) + assert result == {} diff --git a/blog/tests/__init__.py b/blog/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/tests/test_card_fragments.py b/blog/tests/test_card_fragments.py new file mode 100644 index 0000000..e342086 --- /dev/null +++ b/blog/tests/test_card_fragments.py @@ -0,0 +1,44 @@ +"""Unit tests for card fragment parser.""" +from __future__ import annotations + +import pytest + +from blog.bp.blog.services.posts_data import _parse_card_fragments + + +class TestParseCardFragments: + def test_empty_string(self): + assert _parse_card_fragments("") == {} + + def test_single_block(self): + html = '
card
' + result = _parse_card_fragments(html) + assert result == {"42": "
card
"} + + def test_multiple_blocks(self): + html = ( + 'A' + 'B' + ) + result = _parse_card_fragments(html) + assert result == {"1": "A", "2": "B"} + + def test_empty_inner_skipped(self): + html = ' ' + result = _parse_card_fragments(html) + assert result == {} + + def test_multiline_content(self): + html = '\n

line1

\n

line2

\n' + result = _parse_card_fragments(html) + assert "5" in result + assert "

line1

" in result["5"] + + def test_mismatched_ids_not_captured(self): + html = 'content' + result = _parse_card_fragments(html) + assert result == {} + + def test_no_markers(self): + html = '
no markers here
' + assert _parse_card_fragments(html) == {} diff --git a/blog/tests/test_ghost_sync.py b/blog/tests/test_ghost_sync.py new file mode 100644 index 0000000..ecc6ba8 --- /dev/null +++ b/blog/tests/test_ghost_sync.py @@ -0,0 +1,103 @@ +"""Unit tests for Ghost sync helper functions.""" +from __future__ import annotations + +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest + +from blog.bp.blog.ghost.ghost_sync import _iso, _build_ap_post_data + + +class TestIso: + def test_none(self): + assert _iso(None) is None + + def test_empty_string(self): + assert _iso("") is None + + def test_z_suffix(self): + result = _iso("2024-01-15T10:30:00Z") + assert isinstance(result, datetime) + assert result.tzinfo is not None + assert result.year == 2024 + assert result.month == 1 + assert result.hour == 10 + + def test_offset_suffix(self): + result = _iso("2024-06-01T08:00:00+00:00") + assert isinstance(result, datetime) + assert result.hour == 8 + + +class TestBuildApPostData: + def _post(self, **kwargs): + defaults = { + "title": "My Post", + "plaintext": "Some body text.", + "custom_excerpt": None, + "excerpt": None, + "feature_image": None, + "feature_image_alt": None, + "html": None, + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + def _tag(self, slug): + return SimpleNamespace(slug=slug) + + def test_basic_post(self): + post = self._post() + result = _build_ap_post_data(post, "https://blog.example.com/post/", []) + assert result["name"] == "My Post" + assert "My Post" in result["content"] + assert "Some body text." in result["content"] + assert result["url"] == "https://blog.example.com/post/" + + def test_no_title(self): + post = self._post(title=None) + result = _build_ap_post_data(post, "https://example.com/", []) + assert result["name"] == "" + + def test_feature_image(self): + post = self._post(feature_image="https://img.com/photo.jpg", + feature_image_alt="A photo") + result = _build_ap_post_data(post, "https://example.com/", []) + assert "attachment" in result + assert result["attachment"][0]["url"] == "https://img.com/photo.jpg" + assert result["attachment"][0]["name"] == "A photo" + + def test_inline_images_capped_at_4(self): + html = "".join(f'' for i in range(10)) + post = self._post(html=html) + result = _build_ap_post_data(post, "https://example.com/", []) + assert len(result["attachment"]) == 4 + + def test_tags(self): + tags = [self._tag("my-tag"), self._tag("another")] + post = self._post() + result = _build_ap_post_data(post, "https://example.com/", tags) + assert "tag" in result + assert len(result["tag"]) == 2 + assert result["tag"][0]["name"] == "#mytag" # dashes removed + assert result["tag"][0]["type"] == "Hashtag" + + def test_hashtag_in_content(self): + tags = [self._tag("web-dev")] + post = self._post() + result = _build_ap_post_data(post, "https://example.com/", tags) + assert "#webdev" in result["content"] + + def test_no_duplicate_images(self): + post = self._post( + feature_image="https://img.com/same.jpg", + html='', + ) + result = _build_ap_post_data(post, "https://example.com/", []) + assert len(result["attachment"]) == 1 + + def test_multiline_body(self): + post = self._post(plaintext="Para one.\n\nPara two.\n\nPara three.") + result = _build_ap_post_data(post, "https://example.com/", []) + assert result["content"].count("

") >= 4 # title + 3 paras + read more diff --git a/blog/tests/test_lexical_renderer.py b/blog/tests/test_lexical_renderer.py new file mode 100644 index 0000000..fca3a5c --- /dev/null +++ b/blog/tests/test_lexical_renderer.py @@ -0,0 +1,393 @@ +"""Unit tests for the Lexical JSON → HTML renderer.""" +from __future__ import annotations + +import pytest + +from blog.bp.blog.ghost.lexical_renderer import ( + render_lexical, _wrap_format, _align_style, + _FORMAT_BOLD, _FORMAT_ITALIC, _FORMAT_STRIKETHROUGH, + _FORMAT_UNDERLINE, _FORMAT_CODE, _FORMAT_SUBSCRIPT, + _FORMAT_SUPERSCRIPT, _FORMAT_HIGHLIGHT, +) + + +# --------------------------------------------------------------------------- +# _wrap_format +# --------------------------------------------------------------------------- + +class TestWrapFormat: + def test_no_format(self): + assert _wrap_format("hello", 0) == "hello" + + def test_bold(self): + assert _wrap_format("x", _FORMAT_BOLD) == "x" + + def test_italic(self): + assert _wrap_format("x", _FORMAT_ITALIC) == "x" + + def test_strikethrough(self): + assert _wrap_format("x", _FORMAT_STRIKETHROUGH) == "x" + + def test_underline(self): + assert _wrap_format("x", _FORMAT_UNDERLINE) == "x" + + def test_code(self): + assert _wrap_format("x", _FORMAT_CODE) == "x" + + def test_subscript(self): + assert _wrap_format("x", _FORMAT_SUBSCRIPT) == "x" + + def test_superscript(self): + assert _wrap_format("x", _FORMAT_SUPERSCRIPT) == "x" + + def test_highlight(self): + assert _wrap_format("x", _FORMAT_HIGHLIGHT) == "x" + + def test_bold_italic(self): + result = _wrap_format("x", _FORMAT_BOLD | _FORMAT_ITALIC) + assert "" in result + assert "" in result + + def test_all_flags(self): + all_flags = ( + _FORMAT_BOLD | _FORMAT_ITALIC | _FORMAT_STRIKETHROUGH | + _FORMAT_UNDERLINE | _FORMAT_CODE | _FORMAT_SUBSCRIPT | + _FORMAT_SUPERSCRIPT | _FORMAT_HIGHLIGHT + ) + result = _wrap_format("x", all_flags) + for tag in ["strong", "em", "s", "u", "code", "sub", "sup", "mark"]: + assert f"<{tag}>" in result + assert f"" in result + + +# --------------------------------------------------------------------------- +# _align_style +# --------------------------------------------------------------------------- + +class TestAlignStyle: + def test_no_format(self): + assert _align_style({}) == "" + + def test_format_zero(self): + assert _align_style({"format": 0}) == "" + + def test_left(self): + assert _align_style({"format": 1}) == ' style="text-align: left"' + + def test_center(self): + assert _align_style({"format": 2}) == ' style="text-align: center"' + + def test_right(self): + assert _align_style({"format": 3}) == ' style="text-align: right"' + + def test_justify(self): + assert _align_style({"format": 4}) == ' style="text-align: justify"' + + def test_string_format(self): + assert _align_style({"format": "center"}) == ' style="text-align: center"' + + def test_unmapped_int(self): + assert _align_style({"format": 99}) == "" + + +# --------------------------------------------------------------------------- +# render_lexical — text nodes +# --------------------------------------------------------------------------- + +class TestRenderLexicalText: + def test_empty_doc(self): + assert render_lexical({"root": {"children": []}}) == "" + + def test_plain_text(self): + doc = {"root": {"children": [ + {"type": "text", "text": "hello"} + ]}} + assert render_lexical(doc) == "hello" + + def test_html_escape(self): + doc = {"root": {"children": [ + {"type": "text", "text": ""} + ]}} + result = render_lexical(doc) + assert "