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 = '
line1
\nline2
\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 = '
' 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"{tag}>" 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 "