Compare commits
22 Commits
6d43404b12
...
relations
| Author | SHA1 | Date | |
|---|---|---|---|
| bc7a4a5128 | |||
| 8e4c2c139e | |||
| db3f48ec75 | |||
| b40f3d124c | |||
| 3809affcab | |||
| 81e51ae7bc | |||
| b6119b7f04 | |||
| 75cb5d43b9 | |||
| f628b35fc3 | |||
| 2e4fbd5777 | |||
| b47ad6224b | |||
| 2d08d6f787 | |||
| beebe559cd | |||
| b63aa72efb | |||
| 8cfa12de6b | |||
| 3dd62bd9bf | |||
| c926e5221d | |||
| d62643312a | |||
| 8852ab1108 | |||
| 1559c5c931 | |||
| 00efbc2a35 | |||
| 6c44a5f3d0 |
@@ -44,7 +44,7 @@ from .services import (
|
||||
SESSION_USER_KEY = "uid"
|
||||
ACCOUNT_SESSION_KEY = "account_sid"
|
||||
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "artdag", "artdag_l2"}
|
||||
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "orders", "test", "artdag", "artdag_l2"}
|
||||
|
||||
|
||||
def register(url_prefix="/auth"):
|
||||
|
||||
0
account/tests/__init__.py
Normal file
0
account/tests/__init__.py
Normal file
39
account/tests/test_auth_operations.py
Normal file
39
account/tests/test_auth_operations.py
Normal file
@@ -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
|
||||
164
account/tests/test_ghost_membership.py
Normal file
164
account/tests/test_ghost_membership.py
Normal file
@@ -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 == {}
|
||||
@@ -10,8 +10,12 @@
|
||||
(defcomp ~blog-admin-label ()
|
||||
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
|
||||
|
||||
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label)
|
||||
(div :class "relative nav-group" (a :href href :class nav-btn-class label)))
|
||||
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label is-selected select-colours)
|
||||
(div :class "relative nav-group"
|
||||
(a :href href
|
||||
:aria-selected (when is-selected "true")
|
||||
:class (str (or nav-btn-class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3") " " (or select-colours ""))
|
||||
label)))
|
||||
|
||||
(defcomp ~blog-sub-settings-label (&key icon label)
|
||||
(<> (i :class icon :aria-hidden "true") " " label))
|
||||
|
||||
@@ -16,6 +16,7 @@ from shared.sexp.jinja_bridge import render, load_service_components
|
||||
from shared.sexp.helpers import (
|
||||
call_url, get_asset_url, root_header_html,
|
||||
post_header_html as _shared_post_header_html,
|
||||
post_admin_header_html as _shared_post_admin_header_html,
|
||||
oob_header_html,
|
||||
search_mobile_html, search_desktop_html,
|
||||
full_page, oob_page,
|
||||
@@ -50,107 +51,18 @@ def _blog_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the post-level header row (blog-specific: container-nav wrapping + admin cog)."""
|
||||
overrides: dict = {}
|
||||
|
||||
# Blog wraps container_nav_html in border styling
|
||||
container_nav = ctx.get("container_nav_html", "")
|
||||
if container_nav:
|
||||
overrides["container_nav_html"] = render("blog-container-nav",
|
||||
container_nav_html=container_nav,
|
||||
)
|
||||
|
||||
# Admin cog link
|
||||
from quart import url_for as qurl, request
|
||||
post = ctx.get("post") or {}
|
||||
slug = post.get("slug", "")
|
||||
rights = ctx.get("rights") or {}
|
||||
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||
if has_admin and slug:
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
styles = ctx.get("styles") or {}
|
||||
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
|
||||
admin_href = qurl("blog.post.admin.admin", slug=slug)
|
||||
is_admin_page = "/admin" in request.path
|
||||
overrides["post_admin_nav_html"] = render("nav-link",
|
||||
href=admin_href, hx_select="#main-panel", icon="fa fa-cog",
|
||||
aclass=f"{nav_btn} {select_colours}",
|
||||
select_colours=select_colours, is_selected=is_admin_page,
|
||||
)
|
||||
|
||||
effective_ctx = {**ctx, **overrides} if overrides else ctx
|
||||
return _shared_post_header_html(effective_ctx, oob=oob)
|
||||
"""Build the post-level header row — delegates to shared helper."""
|
||||
return _shared_post_header_html(ctx, oob=oob)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post admin header
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Post admin header row with admin icon and nav links."""
|
||||
from quart import url_for as qurl
|
||||
|
||||
post = ctx.get("post") or {}
|
||||
slug = post.get("slug", "")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
styles = ctx.get("styles") or {}
|
||||
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
|
||||
|
||||
admin_href = qurl("blog.post.admin.admin", slug=slug)
|
||||
label_html = render("blog-admin-label")
|
||||
|
||||
nav_html = _post_admin_nav_html(ctx)
|
||||
|
||||
return render("menu-row",
|
||||
id="post-admin-row", level=2,
|
||||
link_href=admin_href, link_label_html=label_html,
|
||||
nav_html=nav_html, child_id="post-admin-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _post_admin_nav_html(ctx: dict) -> str:
|
||||
"""Post admin desktop nav: calendars, markets, payments, entries, data, edit, settings."""
|
||||
from quart import url_for as qurl
|
||||
|
||||
post = ctx.get("post") or {}
|
||||
slug = post.get("slug", "")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
styles = ctx.get("styles") or {}
|
||||
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
|
||||
|
||||
parts = []
|
||||
|
||||
# External links to events / market services
|
||||
events_url_fn = ctx.get("events_url")
|
||||
market_url_fn = ctx.get("market_url")
|
||||
if callable(events_url_fn):
|
||||
for url_fn, path, label in [
|
||||
(events_url_fn, f"/{slug}/admin/", "calendars"),
|
||||
(market_url_fn, f"/{slug}/admin/", "markets"),
|
||||
(ctx.get("cart_url"), f"/{slug}/admin/payments/", "payments"),
|
||||
]:
|
||||
if not callable(url_fn):
|
||||
continue
|
||||
href = url_fn(path)
|
||||
parts.append(render("blog-admin-nav-item",
|
||||
href=href, nav_btn_class=nav_btn, label=label,
|
||||
))
|
||||
|
||||
# HTMX links
|
||||
for endpoint, label in [
|
||||
("blog.post.admin.entries", "entries"),
|
||||
("blog.post.admin.data", "data"),
|
||||
("blog.post.admin.edit", "edit"),
|
||||
("blog.post.admin.settings", "settings"),
|
||||
]:
|
||||
href = qurl(endpoint, slug=slug)
|
||||
parts.append(render("nav-link",
|
||||
href=href, label=label, select_colours=select_colours,
|
||||
))
|
||||
|
||||
return "".join(parts)
|
||||
def _post_admin_header_html(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
|
||||
"""Post admin header row — delegates to shared helper."""
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return _shared_post_admin_header_html(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1415,32 +1327,16 @@ async def render_post_admin_oob(ctx: dict) -> str:
|
||||
async def render_post_data_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = _post_admin_header_html(ctx)
|
||||
from quart import url_for as qurl
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
data_hdr = _post_sub_admin_header_html(
|
||||
"post_data-row", "post_data-header-child",
|
||||
qurl("blog.post.admin.data", slug=slug),
|
||||
"database", "data", ctx,
|
||||
)
|
||||
header_rows = root_hdr + post_hdr + admin_hdr + data_hdr
|
||||
admin_hdr = _post_admin_header_html(ctx, selected="data")
|
||||
header_rows = root_hdr + post_hdr + admin_hdr
|
||||
content = ctx.get("data_html", "")
|
||||
return full_page(ctx, header_rows_html=header_rows, content_html=content)
|
||||
|
||||
|
||||
async def render_post_data_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_html(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
data_hdr = _post_sub_admin_header_html(
|
||||
"post_data-row", "post_data-header-child",
|
||||
qurl("blog.post.admin.data", slug=slug),
|
||||
"database", "data", ctx,
|
||||
)
|
||||
data_oob = _oob_header_html("post-admin-header-child", "post_data-header-child",
|
||||
data_hdr)
|
||||
admin_hdr_oob = _post_admin_header_html(ctx, oob=True, selected="data")
|
||||
content = ctx.get("data_html", "")
|
||||
return oob_page(ctx, oobs_html=admin_hdr_oob + data_oob, content_html=content)
|
||||
return oob_page(ctx, oobs_html=admin_hdr_oob, content_html=content)
|
||||
|
||||
|
||||
# ---- Post entries ----
|
||||
@@ -1448,32 +1344,16 @@ async def render_post_data_oob(ctx: dict) -> str:
|
||||
async def render_post_entries_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = _post_admin_header_html(ctx)
|
||||
from quart import url_for as qurl
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
entries_hdr = _post_sub_admin_header_html(
|
||||
"post_entries-row", "post_entries-header-child",
|
||||
qurl("blog.post.admin.entries", slug=slug),
|
||||
"clock", "entries", ctx,
|
||||
)
|
||||
header_rows = root_hdr + post_hdr + admin_hdr + entries_hdr
|
||||
admin_hdr = _post_admin_header_html(ctx, selected="entries")
|
||||
header_rows = root_hdr + post_hdr + admin_hdr
|
||||
content = ctx.get("entries_html", "")
|
||||
return full_page(ctx, header_rows_html=header_rows, content_html=content)
|
||||
|
||||
|
||||
async def render_post_entries_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_html(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
entries_hdr = _post_sub_admin_header_html(
|
||||
"post_entries-row", "post_entries-header-child",
|
||||
qurl("blog.post.admin.entries", slug=slug),
|
||||
"clock", "entries", ctx,
|
||||
)
|
||||
entries_oob = _oob_header_html("post-admin-header-child", "post_entries-header-child",
|
||||
entries_hdr)
|
||||
admin_hdr_oob = _post_admin_header_html(ctx, oob=True, selected="entries")
|
||||
content = ctx.get("entries_html", "")
|
||||
return oob_page(ctx, oobs_html=admin_hdr_oob + entries_oob, content_html=content)
|
||||
return oob_page(ctx, oobs_html=admin_hdr_oob, content_html=content)
|
||||
|
||||
|
||||
# ---- Post edit ----
|
||||
@@ -1481,15 +1361,8 @@ async def render_post_entries_oob(ctx: dict) -> str:
|
||||
async def render_post_edit_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = _post_admin_header_html(ctx)
|
||||
from quart import url_for as qurl
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
edit_hdr = _post_sub_admin_header_html(
|
||||
"post_edit-row", "post_edit-header-child",
|
||||
qurl("blog.post.admin.edit", slug=slug),
|
||||
"pen-to-square", "edit", ctx,
|
||||
)
|
||||
header_rows = root_hdr + post_hdr + admin_hdr + edit_hdr
|
||||
admin_hdr = _post_admin_header_html(ctx, selected="edit")
|
||||
header_rows = root_hdr + post_hdr + admin_hdr
|
||||
content = ctx.get("edit_html", "")
|
||||
body_end = ctx.get("body_end_html", "")
|
||||
return full_page(ctx, header_rows_html=header_rows, content_html=content,
|
||||
@@ -1497,18 +1370,9 @@ async def render_post_edit_page(ctx: dict) -> str:
|
||||
|
||||
|
||||
async def render_post_edit_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_html(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
edit_hdr = _post_sub_admin_header_html(
|
||||
"post_edit-row", "post_edit-header-child",
|
||||
qurl("blog.post.admin.edit", slug=slug),
|
||||
"pen-to-square", "edit", ctx,
|
||||
)
|
||||
edit_oob = _oob_header_html("post-admin-header-child", "post_edit-header-child",
|
||||
edit_hdr)
|
||||
admin_hdr_oob = _post_admin_header_html(ctx, oob=True, selected="edit")
|
||||
content = ctx.get("edit_html", "")
|
||||
return oob_page(ctx, oobs_html=admin_hdr_oob + edit_oob, content_html=content)
|
||||
return oob_page(ctx, oobs_html=admin_hdr_oob, content_html=content)
|
||||
|
||||
|
||||
# ---- Post settings ----
|
||||
@@ -1516,32 +1380,16 @@ async def render_post_edit_oob(ctx: dict) -> str:
|
||||
async def render_post_settings_page(ctx: dict) -> str:
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = _post_admin_header_html(ctx)
|
||||
from quart import url_for as qurl
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
settings_hdr = _post_sub_admin_header_html(
|
||||
"post_settings-row", "post_settings-header-child",
|
||||
qurl("blog.post.admin.settings", slug=slug),
|
||||
"cog", "settings", ctx,
|
||||
)
|
||||
header_rows = root_hdr + post_hdr + admin_hdr + settings_hdr
|
||||
admin_hdr = _post_admin_header_html(ctx, selected="settings")
|
||||
header_rows = root_hdr + post_hdr + admin_hdr
|
||||
content = ctx.get("settings_html", "")
|
||||
return full_page(ctx, header_rows_html=header_rows, content_html=content)
|
||||
|
||||
|
||||
async def render_post_settings_oob(ctx: dict) -> str:
|
||||
admin_hdr_oob = _post_admin_header_html(ctx, oob=True)
|
||||
from quart import url_for as qurl
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
settings_hdr = _post_sub_admin_header_html(
|
||||
"post_settings-row", "post_settings-header-child",
|
||||
qurl("blog.post.admin.settings", slug=slug),
|
||||
"cog", "settings", ctx,
|
||||
)
|
||||
settings_oob = _oob_header_html("post-admin-header-child", "post_settings-header-child",
|
||||
settings_hdr)
|
||||
admin_hdr_oob = _post_admin_header_html(ctx, oob=True, selected="settings")
|
||||
content = ctx.get("settings_html", "")
|
||||
return oob_page(ctx, oobs_html=admin_hdr_oob + settings_oob, content_html=content)
|
||||
return oob_page(ctx, oobs_html=admin_hdr_oob, content_html=content)
|
||||
|
||||
|
||||
# ---- Settings home ----
|
||||
|
||||
0
blog/tests/__init__.py
Normal file
0
blog/tests/__init__.py
Normal file
44
blog/tests/test_card_fragments.py
Normal file
44
blog/tests/test_card_fragments.py
Normal file
@@ -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-widget:42 --><div>card</div><!-- /card-widget:42 -->'
|
||||
result = _parse_card_fragments(html)
|
||||
assert result == {"42": "<div>card</div>"}
|
||||
|
||||
def test_multiple_blocks(self):
|
||||
html = (
|
||||
'<!-- card-widget:1 -->A<!-- /card-widget:1 -->'
|
||||
'<!-- card-widget:2 -->B<!-- /card-widget:2 -->'
|
||||
)
|
||||
result = _parse_card_fragments(html)
|
||||
assert result == {"1": "A", "2": "B"}
|
||||
|
||||
def test_empty_inner_skipped(self):
|
||||
html = '<!-- card-widget:99 --> <!-- /card-widget:99 -->'
|
||||
result = _parse_card_fragments(html)
|
||||
assert result == {}
|
||||
|
||||
def test_multiline_content(self):
|
||||
html = '<!-- card-widget:5 -->\n<p>line1</p>\n<p>line2</p>\n<!-- /card-widget:5 -->'
|
||||
result = _parse_card_fragments(html)
|
||||
assert "5" in result
|
||||
assert "<p>line1</p>" in result["5"]
|
||||
|
||||
def test_mismatched_ids_not_captured(self):
|
||||
html = '<!-- card-widget:1 -->content<!-- /card-widget:2 -->'
|
||||
result = _parse_card_fragments(html)
|
||||
assert result == {}
|
||||
|
||||
def test_no_markers(self):
|
||||
html = '<div>no markers here</div>'
|
||||
assert _parse_card_fragments(html) == {}
|
||||
103
blog/tests/test_ghost_sync.py
Normal file
103
blog/tests/test_ghost_sync.py
Normal file
@@ -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'<img src="https://img.com/{i}.jpg">' 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='<img src="https://img.com/same.jpg">',
|
||||
)
|
||||
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("<p>") >= 4 # title + 3 paras + read more
|
||||
393
blog/tests/test_lexical_renderer.py
Normal file
393
blog/tests/test_lexical_renderer.py
Normal file
@@ -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) == "<strong>x</strong>"
|
||||
|
||||
def test_italic(self):
|
||||
assert _wrap_format("x", _FORMAT_ITALIC) == "<em>x</em>"
|
||||
|
||||
def test_strikethrough(self):
|
||||
assert _wrap_format("x", _FORMAT_STRIKETHROUGH) == "<s>x</s>"
|
||||
|
||||
def test_underline(self):
|
||||
assert _wrap_format("x", _FORMAT_UNDERLINE) == "<u>x</u>"
|
||||
|
||||
def test_code(self):
|
||||
assert _wrap_format("x", _FORMAT_CODE) == "<code>x</code>"
|
||||
|
||||
def test_subscript(self):
|
||||
assert _wrap_format("x", _FORMAT_SUBSCRIPT) == "<sub>x</sub>"
|
||||
|
||||
def test_superscript(self):
|
||||
assert _wrap_format("x", _FORMAT_SUPERSCRIPT) == "<sup>x</sup>"
|
||||
|
||||
def test_highlight(self):
|
||||
assert _wrap_format("x", _FORMAT_HIGHLIGHT) == "<mark>x</mark>"
|
||||
|
||||
def test_bold_italic(self):
|
||||
result = _wrap_format("x", _FORMAT_BOLD | _FORMAT_ITALIC)
|
||||
assert "<strong>" in result
|
||||
assert "<em>" 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": "<script>alert('xss')</script>"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "<script>" not in result
|
||||
assert "<script>" in result
|
||||
|
||||
def test_bold_text(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "text", "text": "bold", "format": _FORMAT_BOLD}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<strong>bold</strong>"
|
||||
|
||||
def test_string_input(self):
|
||||
import json
|
||||
doc = {"root": {"children": [{"type": "text", "text": "hi"}]}}
|
||||
assert render_lexical(json.dumps(doc)) == "hi"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_lexical — block nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRenderLexicalBlocks:
|
||||
def test_paragraph(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "paragraph", "children": [
|
||||
{"type": "text", "text": "hello"}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<p>hello</p>"
|
||||
|
||||
def test_empty_paragraph(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "paragraph", "children": []}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<p><br></p>"
|
||||
|
||||
def test_heading_default(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "heading", "children": [
|
||||
{"type": "text", "text": "title"}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<h2>title</h2>"
|
||||
|
||||
def test_heading_h3(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "heading", "tag": "h3", "children": [
|
||||
{"type": "text", "text": "title"}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<h3>title</h3>"
|
||||
|
||||
def test_blockquote(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "quote", "children": [
|
||||
{"type": "text", "text": "quoted"}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<blockquote>quoted</blockquote>"
|
||||
|
||||
def test_linebreak(self):
|
||||
doc = {"root": {"children": [{"type": "linebreak"}]}}
|
||||
assert render_lexical(doc) == "<br>"
|
||||
|
||||
def test_horizontal_rule(self):
|
||||
doc = {"root": {"children": [{"type": "horizontalrule"}]}}
|
||||
assert render_lexical(doc) == "<hr>"
|
||||
|
||||
def test_unordered_list(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "list", "listType": "bullet", "children": [
|
||||
{"type": "listitem", "children": [
|
||||
{"type": "text", "text": "item"}
|
||||
]}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<ul><li>item</li></ul>"
|
||||
|
||||
def test_ordered_list(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "list", "listType": "number", "children": [
|
||||
{"type": "listitem", "children": [
|
||||
{"type": "text", "text": "one"}
|
||||
]}
|
||||
]}
|
||||
]}}
|
||||
assert render_lexical(doc) == "<ol><li>one</li></ol>"
|
||||
|
||||
def test_ordered_list_custom_start(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "list", "listType": "number", "start": 5, "children": []}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert 'start="5"' in result
|
||||
|
||||
def test_link(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "link", "url": "https://example.com", "children": [
|
||||
{"type": "text", "text": "click"}
|
||||
]}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert 'href="https://example.com"' in result
|
||||
assert "click" in result
|
||||
|
||||
def test_link_xss_url(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "link", "url": 'javascript:alert("xss")', "children": []}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "javascript:alert("xss")" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_lexical — cards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRenderLexicalCards:
|
||||
def test_image(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "image", "src": "photo.jpg", "alt": "A photo"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-image-card" in result
|
||||
assert 'src="photo.jpg"' in result
|
||||
assert 'alt="A photo"' in result
|
||||
assert 'loading="lazy"' in result
|
||||
|
||||
def test_image_wide(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "image", "src": "x.jpg", "cardWidth": "wide"}
|
||||
]}}
|
||||
assert "kg-width-wide" in render_lexical(doc)
|
||||
|
||||
def test_image_with_caption(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "image", "src": "x.jpg", "caption": "Caption text"}
|
||||
]}}
|
||||
assert "<figcaption>Caption text</figcaption>" in render_lexical(doc)
|
||||
|
||||
def test_codeblock(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "codeblock", "code": "print('hi')", "language": "python"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert 'class="language-python"' in result
|
||||
assert "print('hi')" in result
|
||||
|
||||
def test_html_card(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "html", "html": "<div>raw</div>"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "<!--kg-card-begin: html-->" in result
|
||||
assert "<div>raw</div>" in result
|
||||
|
||||
def test_markdown_card(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "markdown", "markdown": "**bold**"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "<!--kg-card-begin: markdown-->" in result
|
||||
assert "<strong>bold</strong>" in result
|
||||
|
||||
def test_callout(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "callout", "backgroundColor": "blue",
|
||||
"calloutEmoji": "💡", "children": [
|
||||
{"type": "text", "text": "Note"}
|
||||
]}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-callout-card-blue" in result
|
||||
assert "💡" in result
|
||||
|
||||
def test_button(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "button", "buttonText": "Click",
|
||||
"buttonUrl": "https://example.com", "alignment": "left"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-align-left" in result
|
||||
assert "Click" in result
|
||||
|
||||
def test_toggle(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "toggle", "heading": "FAQ", "children": [
|
||||
{"type": "text", "text": "Answer"}
|
||||
]}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-toggle-card" in result
|
||||
assert "FAQ" in result
|
||||
|
||||
def test_audio_duration(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "audio", "src": "a.mp3", "title": "Song", "duration": 185}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "3:05" in result
|
||||
|
||||
def test_audio_zero_duration(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "audio", "src": "a.mp3", "duration": 0}
|
||||
]}}
|
||||
assert "0:00" in render_lexical(doc)
|
||||
|
||||
def test_video(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "video", "src": "v.mp4", "loop": True}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-video-card" in result
|
||||
assert " loop" in result
|
||||
|
||||
def test_file_size_kb(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "file", "src": "f.pdf", "fileName": "doc.pdf",
|
||||
"fileSize": 512000} # 500 KB
|
||||
]}}
|
||||
assert "500 KB" in render_lexical(doc)
|
||||
|
||||
def test_file_size_mb(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "file", "src": "f.zip", "fileName": "big.zip",
|
||||
"fileSize": 5242880} # 5 MB
|
||||
]}}
|
||||
assert "5.0 MB" in render_lexical(doc)
|
||||
|
||||
def test_paywall(self):
|
||||
doc = {"root": {"children": [{"type": "paywall"}]}}
|
||||
assert render_lexical(doc) == "<!--members-only-->"
|
||||
|
||||
def test_embed(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "embed", "html": "<iframe></iframe>",
|
||||
"caption": "Video"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-embed-card" in result
|
||||
assert "<figcaption>Video</figcaption>" in result
|
||||
|
||||
def test_bookmark(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "bookmark", "url": "https://example.com",
|
||||
"metadata": {"title": "Example", "description": "A site"}}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-bookmark-card" in result
|
||||
assert "Example" in result
|
||||
|
||||
def test_unknown_node_ignored(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "unknown-future-thing"}
|
||||
]}}
|
||||
assert render_lexical(doc) == ""
|
||||
|
||||
def test_product_stars(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "product", "productTitle": "Widget",
|
||||
"rating": 3, "productDescription": "Nice"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-product-card" in result
|
||||
assert result.count("kg-product-card-rating-active") == 3
|
||||
|
||||
def test_header_card(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "header", "heading": "Welcome",
|
||||
"size": "large", "style": "dark"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-header-card" in result
|
||||
assert "kg-size-large" in result
|
||||
assert "Welcome" in result
|
||||
|
||||
def test_signup_card(self):
|
||||
doc = {"root": {"children": [
|
||||
{"type": "signup", "heading": "Subscribe",
|
||||
"buttonText": "Join", "style": "light"}
|
||||
]}}
|
||||
result = render_lexical(doc)
|
||||
assert "kg-signup-card" in result
|
||||
assert "Join" in result
|
||||
83
blog/tests/test_lexical_validator.py
Normal file
83
blog/tests/test_lexical_validator.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Unit tests for lexical document validator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from blog.bp.blog.ghost.lexical_validator import (
|
||||
validate_lexical, ALLOWED_NODE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateLexical:
|
||||
def test_valid_empty_doc(self):
|
||||
ok, reason = validate_lexical({"root": {"type": "root", "children": []}})
|
||||
assert ok is True
|
||||
assert reason is None
|
||||
|
||||
def test_non_dict_input(self):
|
||||
ok, reason = validate_lexical("not a dict")
|
||||
assert ok is False
|
||||
assert "JSON object" in reason
|
||||
|
||||
def test_list_input(self):
|
||||
ok, reason = validate_lexical([])
|
||||
assert ok is False
|
||||
|
||||
def test_missing_root(self):
|
||||
ok, reason = validate_lexical({"foo": "bar"})
|
||||
assert ok is False
|
||||
assert "'root'" in reason
|
||||
|
||||
def test_root_not_dict(self):
|
||||
ok, reason = validate_lexical({"root": "string"})
|
||||
assert ok is False
|
||||
|
||||
def test_valid_paragraph(self):
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"type": "paragraph", "children": [
|
||||
{"type": "text", "text": "hello"}
|
||||
]}
|
||||
]}}
|
||||
ok, _ = validate_lexical(doc)
|
||||
assert ok is True
|
||||
|
||||
def test_disallowed_type(self):
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"type": "script"}
|
||||
]}}
|
||||
ok, reason = validate_lexical(doc)
|
||||
assert ok is False
|
||||
assert "Disallowed node type: script" in reason
|
||||
|
||||
def test_nested_disallowed_type(self):
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"type": "paragraph", "children": [
|
||||
{"type": "list", "children": [
|
||||
{"type": "evil-widget"}
|
||||
]}
|
||||
]}
|
||||
]}}
|
||||
ok, reason = validate_lexical(doc)
|
||||
assert ok is False
|
||||
assert "evil-widget" in reason
|
||||
|
||||
def test_node_without_type_allowed(self):
|
||||
"""Nodes with type=None are allowed by _walk."""
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"children": []} # no "type" key
|
||||
]}}
|
||||
ok, _ = validate_lexical(doc)
|
||||
assert ok is True
|
||||
|
||||
def test_all_allowed_types(self):
|
||||
"""Every type in the allowlist should pass."""
|
||||
for node_type in ALLOWED_NODE_TYPES:
|
||||
doc = {"root": {"type": "root", "children": [
|
||||
{"type": node_type, "children": []}
|
||||
]}}
|
||||
ok, reason = validate_lexical(doc)
|
||||
assert ok is True, f"{node_type} should be allowed but got: {reason}"
|
||||
|
||||
def test_allowed_types_count(self):
|
||||
"""Sanity: at least 30 types in the allowlist."""
|
||||
assert len(ALLOWED_NODE_TYPES) >= 30
|
||||
50
blog/tests/test_slugify.py
Normal file
50
blog/tests/test_slugify.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Unit tests for blog slugify utility."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from blog.bp.post.services.markets import slugify
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_basic_ascii(self):
|
||||
assert slugify("Hello World") == "hello-world"
|
||||
|
||||
def test_unicode_stripped(self):
|
||||
assert slugify("café") == "cafe"
|
||||
|
||||
def test_slashes_to_dashes(self):
|
||||
assert slugify("foo/bar") == "foo-bar"
|
||||
|
||||
def test_special_chars(self):
|
||||
assert slugify("foo!!bar") == "foo-bar"
|
||||
|
||||
def test_multiple_dashes_collapsed(self):
|
||||
assert slugify("foo---bar") == "foo-bar"
|
||||
|
||||
def test_leading_trailing_dashes_stripped(self):
|
||||
assert slugify("--foo--") == "foo"
|
||||
|
||||
def test_empty_string_fallback(self):
|
||||
assert slugify("") == "market"
|
||||
|
||||
def test_none_fallback(self):
|
||||
assert slugify(None) == "market"
|
||||
|
||||
def test_max_len_truncation(self):
|
||||
result = slugify("a" * 300, max_len=10)
|
||||
assert len(result) <= 10
|
||||
|
||||
def test_truncation_no_trailing_dash(self):
|
||||
# "abcde-fgh" truncated to 5 should not end with dash
|
||||
result = slugify("abcde fgh", max_len=5)
|
||||
assert not result.endswith("-")
|
||||
|
||||
def test_already_clean(self):
|
||||
assert slugify("hello-world") == "hello-world"
|
||||
|
||||
def test_numbers_preserved(self):
|
||||
assert slugify("item-42") == "item-42"
|
||||
|
||||
def test_accented_characters(self):
|
||||
assert slugify("über straße") == "uber-strae"
|
||||
@@ -8,11 +8,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from markupsafe import escape
|
||||
|
||||
from shared.sexp.jinja_bridge import render, load_service_components
|
||||
from shared.sexp.helpers import (
|
||||
call_url, root_header_html, search_desktop_html,
|
||||
search_mobile_html, full_page, oob_page,
|
||||
call_url, root_header_html, post_admin_header_html,
|
||||
post_header_html as _shared_post_header_html,
|
||||
search_desktop_html, search_mobile_html, full_page, oob_page,
|
||||
)
|
||||
from shared.infrastructure.urls import market_product_url, cart_url
|
||||
|
||||
@@ -24,6 +26,48 @@ load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
||||
# Header helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
|
||||
"""Ensure ctx has a 'post' dict from page_post DTO (for shared post_header_html)."""
|
||||
if ctx.get("post") or not page_post:
|
||||
return ctx
|
||||
ctx = {**ctx, "post": {
|
||||
"id": getattr(page_post, "id", None),
|
||||
"slug": getattr(page_post, "slug", ""),
|
||||
"title": getattr(page_post, "title", ""),
|
||||
"feature_image": getattr(page_post, "feature_image", None),
|
||||
}}
|
||||
return ctx
|
||||
|
||||
|
||||
async def _ensure_container_nav(ctx: dict) -> dict:
|
||||
"""Fetch container_nav_html if not already present (for post header row)."""
|
||||
if ctx.get("container_nav_html"):
|
||||
return ctx
|
||||
post = ctx.get("post") or {}
|
||||
post_id = post.get("id")
|
||||
slug = post.get("slug", "")
|
||||
if not post_id:
|
||||
return ctx
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
nav_params = {
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": slug,
|
||||
}
|
||||
events_nav, market_nav = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
return {**ctx, "container_nav_html": events_nav + market_nav}
|
||||
|
||||
|
||||
async def _post_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
"""Build post-level header row from page_post DTO, using shared helper."""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
return _shared_post_header_html(ctx, oob=oob)
|
||||
|
||||
|
||||
def _cart_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the cart section header row."""
|
||||
return render(
|
||||
@@ -511,17 +555,13 @@ async def render_overview_page(ctx: dict, page_groups: list) -> str:
|
||||
"""Full page: cart overview."""
|
||||
main = _overview_main_panel_html(page_groups, ctx)
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render("cart-header-child", inner_html=_cart_header_html(ctx))
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=main)
|
||||
|
||||
|
||||
async def render_overview_oob(ctx: dict, page_groups: list) -> str:
|
||||
"""OOB response for cart overview."""
|
||||
main = _overview_main_panel_html(page_groups, ctx)
|
||||
oobs = (
|
||||
_cart_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
oobs = root_header_html(ctx, oob=True)
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=main)
|
||||
|
||||
|
||||
@@ -717,7 +757,6 @@ def _checkout_error_content_html(error: str | None, order: Any | None) -> str:
|
||||
async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str:
|
||||
"""Full page: checkout error."""
|
||||
hdr = root_header_html(ctx)
|
||||
hdr += render("cart-header-child", inner_html=_cart_header_html(ctx))
|
||||
filt = _checkout_error_filter_html()
|
||||
content = _checkout_error_content_html(error, order)
|
||||
return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content)
|
||||
@@ -727,23 +766,12 @@ async def render_checkout_error_page(ctx: dict, error: str | None = None, order:
|
||||
# Page admin (/<page_slug>/admin/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cart_page_admin_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
"""Build the page-level admin header row."""
|
||||
from quart import url_for
|
||||
link_href = url_for("page_admin.admin")
|
||||
return render("menu-row", id="page-admin-row", level=2, colour="sky",
|
||||
link_href=link_href, link_label="admin", icon="fa fa-cog",
|
||||
child_id="page-admin-header-child", oob=oob)
|
||||
|
||||
|
||||
def _cart_payments_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the payments section header row."""
|
||||
from quart import url_for
|
||||
link_href = url_for("page_admin.payments")
|
||||
return render("menu-row", id="payments-row", level=3, colour="sky",
|
||||
link_href=link_href, link_label="Payments",
|
||||
icon="fa fa-credit-card",
|
||||
child_id="payments-header-child", oob=oob)
|
||||
def _cart_page_admin_header_html(ctx: dict, page_post: Any, *, oob: bool = False,
|
||||
selected: str = "") -> str:
|
||||
"""Build the page-level admin header row — delegates to shared helper."""
|
||||
slug = page_post.slug if page_post else ""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
return post_admin_header_html(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
def _cart_admin_main_panel_html(ctx: dict) -> str:
|
||||
@@ -788,24 +816,16 @@ def _cart_payments_main_panel_html(ctx: dict) -> str:
|
||||
async def render_cart_admin_page(ctx: dict, page_post: Any) -> str:
|
||||
"""Full page: cart page admin overview."""
|
||||
content = _cart_admin_main_panel_html(ctx)
|
||||
hdr = root_header_html(ctx)
|
||||
child = _page_cart_header_html(ctx, page_post) + _cart_page_admin_header_html(ctx, page_post)
|
||||
hdr += render("cart-header-child-nested",
|
||||
outer_html=_cart_header_html(ctx), inner_html=child)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = await _post_header_html(ctx, page_post)
|
||||
admin_hdr = _cart_page_admin_header_html(ctx, page_post)
|
||||
return full_page(ctx, header_rows_html=root_hdr + post_hdr + admin_hdr, content_html=content)
|
||||
|
||||
|
||||
async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str:
|
||||
"""OOB response: cart page admin overview."""
|
||||
content = _cart_admin_main_panel_html(ctx)
|
||||
oobs = (
|
||||
_cart_page_admin_header_html(ctx, page_post, oob=True)
|
||||
+ render("cart-header-child-oob",
|
||||
inner_html=_page_cart_header_html(ctx, page_post)
|
||||
+ _cart_page_admin_header_html(ctx, page_post))
|
||||
+ _cart_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
oobs = _cart_page_admin_header_html(ctx, page_post, oob=True)
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
@@ -816,28 +836,16 @@ async def render_cart_admin_oob(ctx: dict, page_post: Any) -> str:
|
||||
async def render_cart_payments_page(ctx: dict, page_post: Any) -> str:
|
||||
"""Full page: payments config."""
|
||||
content = _cart_payments_main_panel_html(ctx)
|
||||
hdr = root_header_html(ctx)
|
||||
admin_hdr = _cart_page_admin_header_html(ctx, page_post)
|
||||
payments_hdr = _cart_payments_header_html(ctx)
|
||||
child = _page_cart_header_html(ctx, page_post) + admin_hdr + payments_hdr
|
||||
hdr += render("cart-header-child-nested",
|
||||
outer_html=_cart_header_html(ctx), inner_html=child)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = await _post_header_html(ctx, page_post)
|
||||
admin_hdr = _cart_page_admin_header_html(ctx, page_post, selected="payments")
|
||||
return full_page(ctx, header_rows_html=root_hdr + post_hdr + admin_hdr, content_html=content)
|
||||
|
||||
|
||||
async def render_cart_payments_oob(ctx: dict, page_post: Any) -> str:
|
||||
"""OOB response: payments config."""
|
||||
content = _cart_payments_main_panel_html(ctx)
|
||||
admin_hdr = _cart_page_admin_header_html(ctx, page_post)
|
||||
payments_hdr = _cart_payments_header_html(ctx)
|
||||
oobs = (
|
||||
_cart_payments_header_html(ctx, oob=True)
|
||||
+ render("cart-header-child-oob",
|
||||
inner_html=_page_cart_header_html(ctx, page_post)
|
||||
+ admin_hdr + payments_hdr)
|
||||
+ _cart_header_html(ctx, oob=True)
|
||||
+ root_header_html(ctx, oob=True)
|
||||
)
|
||||
oobs = _cart_page_admin_header_html(ctx, page_post, oob=True, selected="payments")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
|
||||
0
cart/tests/__init__.py
Normal file
0
cart/tests/__init__.py
Normal file
59
cart/tests/test_calendar_cart.py
Normal file
59
cart/tests/test_calendar_cart.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Unit tests for calendar/ticket total functions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from cart.bp.cart.services.calendar_cart import calendar_total, ticket_total
|
||||
|
||||
|
||||
def _entry(cost):
|
||||
return SimpleNamespace(cost=cost)
|
||||
|
||||
|
||||
def _ticket(price):
|
||||
return SimpleNamespace(price=price)
|
||||
|
||||
|
||||
class TestCalendarTotal:
|
||||
def test_empty(self):
|
||||
assert calendar_total([]) == 0
|
||||
|
||||
def test_single_entry(self):
|
||||
assert calendar_total([_entry(25.0)]) == Decimal("25.0")
|
||||
|
||||
def test_none_cost_excluded(self):
|
||||
result = calendar_total([_entry(None)])
|
||||
assert result == 0
|
||||
|
||||
def test_zero_cost(self):
|
||||
# cost=0 is falsy, so it produces Decimal(0) via the else branch
|
||||
result = calendar_total([_entry(0)])
|
||||
assert result == Decimal("0")
|
||||
|
||||
def test_multiple(self):
|
||||
result = calendar_total([_entry(10.0), _entry(20.0)])
|
||||
assert result == Decimal("30.0")
|
||||
|
||||
|
||||
class TestTicketTotal:
|
||||
def test_empty(self):
|
||||
assert ticket_total([]) == Decimal("0")
|
||||
|
||||
def test_single_ticket(self):
|
||||
assert ticket_total([_ticket(15.0)]) == Decimal("15.0")
|
||||
|
||||
def test_none_price_treated_as_zero(self):
|
||||
# ticket_total includes all tickets, None → Decimal(0)
|
||||
result = ticket_total([_ticket(None)])
|
||||
assert result == Decimal("0")
|
||||
|
||||
def test_multiple(self):
|
||||
result = ticket_total([_ticket(5.0), _ticket(10.0)])
|
||||
assert result == Decimal("15.0")
|
||||
|
||||
def test_mixed_with_none(self):
|
||||
result = ticket_total([_ticket(10.0), _ticket(None), _ticket(5.0)])
|
||||
assert result == Decimal("15.0")
|
||||
139
cart/tests/test_checkout.py
Normal file
139
cart/tests/test_checkout.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Unit tests for cart checkout helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cart.bp.cart.services.checkout import (
|
||||
build_sumup_description,
|
||||
build_sumup_reference,
|
||||
build_webhook_url,
|
||||
validate_webhook_secret,
|
||||
)
|
||||
|
||||
|
||||
def _ci(title=None, qty=1):
|
||||
return SimpleNamespace(product_title=title, quantity=qty)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_sumup_description
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildSumupDescription:
|
||||
def test_empty_cart_no_tickets(self):
|
||||
result = build_sumup_description([], 1)
|
||||
assert "Order 1" in result
|
||||
assert "0 items" in result
|
||||
assert "order items" in result
|
||||
|
||||
def test_single_item(self):
|
||||
result = build_sumup_description([_ci("Widget", 1)], 42)
|
||||
assert "Order 42" in result
|
||||
assert "1 item)" in result
|
||||
assert "Widget" in result
|
||||
|
||||
def test_quantity_counted(self):
|
||||
result = build_sumup_description([_ci("Widget", 3)], 1)
|
||||
assert "3 items" in result
|
||||
|
||||
def test_three_titles(self):
|
||||
items = [_ci("A"), _ci("B"), _ci("C")]
|
||||
result = build_sumup_description(items, 1)
|
||||
assert "A, B, C" in result
|
||||
assert "more" not in result
|
||||
|
||||
def test_four_titles_truncated(self):
|
||||
items = [_ci("A"), _ci("B"), _ci("C"), _ci("D")]
|
||||
result = build_sumup_description(items, 1)
|
||||
assert "A, B, C" in result
|
||||
assert "+ 1 more" in result
|
||||
|
||||
def test_none_titles_excluded(self):
|
||||
items = [_ci(None), _ci("Visible")]
|
||||
result = build_sumup_description(items, 1)
|
||||
assert "Visible" in result
|
||||
|
||||
def test_tickets_singular(self):
|
||||
result = build_sumup_description([], 1, ticket_count=1)
|
||||
assert "1 ticket" in result
|
||||
assert "tickets" not in result
|
||||
|
||||
def test_tickets_plural(self):
|
||||
result = build_sumup_description([], 1, ticket_count=3)
|
||||
assert "3 tickets" in result
|
||||
|
||||
def test_mixed_items_and_tickets(self):
|
||||
result = build_sumup_description([_ci("A", 2)], 1, ticket_count=1)
|
||||
assert "3 items" in result # 2 + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_sumup_reference
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildSumupReference:
|
||||
def test_with_page_config(self):
|
||||
pc = SimpleNamespace(sumup_checkout_prefix="SHOP-")
|
||||
assert build_sumup_reference(42, pc) == "SHOP-42"
|
||||
|
||||
def test_without_page_config(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"checkout_reference_prefix": "RA-"}}):
|
||||
assert build_sumup_reference(99) == "RA-99"
|
||||
|
||||
def test_no_prefix(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {}}):
|
||||
assert build_sumup_reference(1) == "1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_webhook_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildWebhookUrl:
|
||||
def test_no_secret(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {}}):
|
||||
assert build_webhook_url("https://x.com/hook") == "https://x.com/hook"
|
||||
|
||||
def test_with_secret_no_query(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "s3cret"}}):
|
||||
result = build_webhook_url("https://x.com/hook")
|
||||
assert "?token=s3cret" in result
|
||||
|
||||
def test_with_secret_existing_query(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "s3cret"}}):
|
||||
result = build_webhook_url("https://x.com/hook?a=1")
|
||||
assert "&token=s3cret" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_webhook_secret
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidateWebhookSecret:
|
||||
def test_no_secret_configured(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {}}):
|
||||
assert validate_webhook_secret(None) is True
|
||||
|
||||
def test_correct_token(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "abc"}}):
|
||||
assert validate_webhook_secret("abc") is True
|
||||
|
||||
def test_wrong_token(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "abc"}}):
|
||||
assert validate_webhook_secret("wrong") is False
|
||||
|
||||
def test_none_token_with_secret(self):
|
||||
with patch("cart.bp.cart.services.checkout.config",
|
||||
return_value={"sumup": {"webhook_secret": "abc"}}):
|
||||
assert validate_webhook_secret(None) is False
|
||||
77
cart/tests/test_ticket_groups.py
Normal file
77
cart/tests/test_ticket_groups.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Unit tests for ticket grouping logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from cart.bp.cart.services.ticket_groups import group_tickets
|
||||
|
||||
|
||||
def _ticket(entry_id=1, entry_name="Event", ticket_type_id=None,
|
||||
ticket_type_name=None, price=10.0,
|
||||
entry_start_at=None, entry_end_at=None):
|
||||
return SimpleNamespace(
|
||||
entry_id=entry_id,
|
||||
entry_name=entry_name,
|
||||
entry_start_at=entry_start_at or datetime(2025, 6, 1, 10, 0),
|
||||
entry_end_at=entry_end_at,
|
||||
ticket_type_id=ticket_type_id,
|
||||
ticket_type_name=ticket_type_name,
|
||||
price=price,
|
||||
)
|
||||
|
||||
|
||||
class TestGroupTickets:
|
||||
def test_empty(self):
|
||||
assert group_tickets([]) == []
|
||||
|
||||
def test_single_ticket(self):
|
||||
result = group_tickets([_ticket()])
|
||||
assert len(result) == 1
|
||||
assert result[0]["quantity"] == 1
|
||||
assert result[0]["line_total"] == 10.0
|
||||
|
||||
def test_same_group_merged(self):
|
||||
tickets = [_ticket(entry_id=1), _ticket(entry_id=1)]
|
||||
result = group_tickets(tickets)
|
||||
assert len(result) == 1
|
||||
assert result[0]["quantity"] == 2
|
||||
assert result[0]["line_total"] == 20.0
|
||||
|
||||
def test_different_entries_separate(self):
|
||||
tickets = [_ticket(entry_id=1), _ticket(entry_id=2)]
|
||||
result = group_tickets(tickets)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_different_ticket_types_separate(self):
|
||||
tickets = [
|
||||
_ticket(entry_id=1, ticket_type_id=1, ticket_type_name="Adult"),
|
||||
_ticket(entry_id=1, ticket_type_id=2, ticket_type_name="Child"),
|
||||
]
|
||||
result = group_tickets(tickets)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_none_price(self):
|
||||
result = group_tickets([_ticket(price=None)])
|
||||
assert result[0]["line_total"] == 0.0
|
||||
|
||||
def test_ordering_preserved(self):
|
||||
tickets = [
|
||||
_ticket(entry_id=2, entry_name="Second"),
|
||||
_ticket(entry_id=1, entry_name="First"),
|
||||
]
|
||||
result = group_tickets(tickets)
|
||||
assert result[0]["entry_name"] == "Second"
|
||||
assert result[1]["entry_name"] == "First"
|
||||
|
||||
def test_metadata_from_first_ticket(self):
|
||||
tickets = [
|
||||
_ticket(entry_id=1, entry_name="A", price=5.0),
|
||||
_ticket(entry_id=1, entry_name="B", price=10.0),
|
||||
]
|
||||
result = group_tickets(tickets)
|
||||
assert result[0]["entry_name"] == "A" # from first
|
||||
assert result[0]["price"] == 5.0 # from first
|
||||
assert result[0]["line_total"] == 15.0 # accumulated
|
||||
47
cart/tests/test_total.py
Normal file
47
cart/tests/test_total.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Unit tests for cart total calculations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from cart.bp.cart.services.total import total
|
||||
|
||||
|
||||
def _item(special=None, regular=None, qty=1):
|
||||
return SimpleNamespace(
|
||||
product_special_price=special,
|
||||
product_regular_price=regular,
|
||||
quantity=qty,
|
||||
)
|
||||
|
||||
|
||||
class TestTotal:
|
||||
def test_empty_cart(self):
|
||||
assert total([]) == 0
|
||||
|
||||
def test_regular_price_only(self):
|
||||
result = total([_item(regular=10.0, qty=2)])
|
||||
assert result == Decimal("20.0")
|
||||
|
||||
def test_special_price_preferred(self):
|
||||
result = total([_item(special=5.0, regular=10.0, qty=1)])
|
||||
assert result == Decimal("5.0")
|
||||
|
||||
def test_none_prices_excluded(self):
|
||||
result = total([_item(special=None, regular=None, qty=1)])
|
||||
assert result == 0
|
||||
|
||||
def test_mixed_items(self):
|
||||
items = [
|
||||
_item(special=5.0, qty=2), # 10
|
||||
_item(regular=3.0, qty=3), # 9
|
||||
_item(), # excluded
|
||||
]
|
||||
result = total(items)
|
||||
assert result == Decimal("19.0")
|
||||
|
||||
def test_quantity_multiplication(self):
|
||||
result = total([_item(regular=7.5, qty=4)])
|
||||
assert result == Decimal("30.0")
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="registry.rose-ash.com:5000"
|
||||
APPS="blog market cart events federation account relations likes orders"
|
||||
APPS="blog market cart events federation account relations likes orders test"
|
||||
|
||||
usage() {
|
||||
echo "Usage: deploy.sh [app ...]"
|
||||
|
||||
4
dev.sh
4
dev.sh
@@ -20,8 +20,8 @@ case "${1:-up}" in
|
||||
shift
|
||||
$COMPOSE logs -f "$@"
|
||||
;;
|
||||
test)
|
||||
# One-shot: all unit tests
|
||||
test-run)
|
||||
# One-shot: all unit tests (headless, no dashboard)
|
||||
$COMPOSE run --rm test-unit python -m pytest \
|
||||
shared/ artdag/core/tests/ artdag/core/artdag/sexp/ \
|
||||
artdag/l1/tests/ artdag/l1/sexp_effects/ \
|
||||
|
||||
@@ -351,6 +351,33 @@ services:
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
|
||||
test:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8011:8000"
|
||||
environment:
|
||||
<<: *dev-env
|
||||
volumes:
|
||||
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
|
||||
- ./shared:/app/shared
|
||||
- ./test/app.py:/app/app.py
|
||||
- ./test/sexp:/app/sexp
|
||||
- ./test/bp:/app/bp
|
||||
- ./test/services:/app/services
|
||||
- ./test/runner.py:/app/runner.py
|
||||
- ./test/path_setup.py:/app/path_setup.py
|
||||
- ./test/entrypoint.sh:/usr/local/bin/entrypoint.sh
|
||||
# sibling service code + tests
|
||||
- ./blog:/app/blog:ro
|
||||
- ./market:/app/market:ro
|
||||
- ./cart:/app/cart:ro
|
||||
- ./events:/app/events:ro
|
||||
- ./federation:/app/federation:ro
|
||||
- ./account:/app/account:ro
|
||||
- ./relations:/app/relations:ro
|
||||
- ./likes:/app/likes:ro
|
||||
- ./orders:/app/orders:ro
|
||||
|
||||
test-unit:
|
||||
build:
|
||||
context: .
|
||||
|
||||
@@ -35,6 +35,7 @@ x-app-env: &app-env
|
||||
APP_URL_ORDERS: https://orders.rose-ash.com
|
||||
APP_URL_RELATIONS: http://relations:8000
|
||||
APP_URL_LIKES: http://likes:8000
|
||||
APP_URL_TEST: https://test.rose-ash.com
|
||||
APP_URL_ARTDAG: https://celery-artdag.rose-ash.com
|
||||
APP_URL_ARTDAG_L2: https://artdag.rose-ash.com
|
||||
INTERNAL_URL_BLOG: http://blog:8000
|
||||
@@ -46,6 +47,7 @@ x-app-env: &app-env
|
||||
INTERNAL_URL_ORDERS: http://orders:8000
|
||||
INTERNAL_URL_RELATIONS: http://relations:8000
|
||||
INTERNAL_URL_LIKES: http://likes:8000
|
||||
INTERNAL_URL_TEST: http://test:8000
|
||||
INTERNAL_URL_ARTDAG: http://l1-server:8100
|
||||
AP_DOMAIN: federation.rose-ash.com
|
||||
AP_DOMAIN_BLOG: blog.rose-ash.com
|
||||
@@ -201,6 +203,17 @@ services:
|
||||
RUN_MIGRATIONS: "true"
|
||||
WORKERS: "1"
|
||||
|
||||
test:
|
||||
<<: *app-common
|
||||
image: registry.rose-ash.com:5000/test:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: test/Dockerfile
|
||||
environment:
|
||||
<<: *app-env
|
||||
REDIS_URL: redis://redis:6379/9
|
||||
WORKERS: "1"
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
|
||||
@@ -15,6 +15,7 @@ from shared.sexp.jinja_bridge import render, load_service_components
|
||||
from shared.sexp.helpers import (
|
||||
call_url, get_asset_url, root_header_html,
|
||||
post_header_html as _shared_post_header_html,
|
||||
post_admin_header_html,
|
||||
oob_header_html,
|
||||
search_mobile_html, search_desktop_html,
|
||||
full_page, oob_page,
|
||||
@@ -35,11 +36,55 @@ _oob_header_html = oob_header_html
|
||||
# Post header helpers — thin wrapper over shared post_header_html
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _clear_oob(*ids: str) -> str:
|
||||
"""Generate OOB swaps to remove orphaned header rows/children."""
|
||||
return "".join(f'<div id="{i}" hx-swap-oob="outerHTML"></div>' for i in ids)
|
||||
|
||||
|
||||
# All possible header row/child IDs at each depth (deepest first)
|
||||
_EVENTS_DEEP_IDS = [
|
||||
"entry-admin-row", "entry-admin-header-child",
|
||||
"entry-row", "entry-header-child",
|
||||
"day-admin-row", "day-admin-header-child",
|
||||
"day-row", "day-header-child",
|
||||
"calendar-admin-row", "calendar-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"calendars-row", "calendars-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
]
|
||||
|
||||
|
||||
def _clear_deeper_oob(*keep_ids: str) -> str:
|
||||
"""Clear all events header rows/children NOT in keep_ids."""
|
||||
to_clear = [i for i in _EVENTS_DEEP_IDS if i not in keep_ids]
|
||||
return _clear_oob(*to_clear)
|
||||
|
||||
|
||||
async def _ensure_container_nav(ctx: dict) -> dict:
|
||||
"""Fetch container_nav_html if not already present (for post header row)."""
|
||||
if ctx.get("container_nav_html"):
|
||||
return ctx
|
||||
post = ctx.get("post") or {}
|
||||
post_id = post.get("id")
|
||||
slug = post.get("slug", "")
|
||||
if not post_id:
|
||||
return ctx
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
nav_params = {
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": slug,
|
||||
}
|
||||
events_nav, market_nav = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
return {**ctx, "container_nav_html": events_nav + market_nav}
|
||||
|
||||
|
||||
def _post_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the post-level header row (events-specific: calendar links + admin cog)."""
|
||||
admin_nav = _post_nav_html(ctx)
|
||||
effective_ctx = {**ctx, "post_admin_nav_html": admin_nav} if admin_nav else ctx
|
||||
return _shared_post_header_html(effective_ctx, oob=oob)
|
||||
"""Build the post-level header row — delegates to shared helper."""
|
||||
return _shared_post_header_html(ctx, oob=oob)
|
||||
|
||||
|
||||
def _post_nav_html(ctx: dict) -> str:
|
||||
@@ -1236,6 +1281,7 @@ async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets,
|
||||
)
|
||||
|
||||
oobs = _post_header_html(ctx, oob=True)
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
@@ -1264,18 +1310,22 @@ async def render_page_summary_cards(entries, has_more, pending_tickets,
|
||||
async def render_calendars_page(ctx: dict) -> str:
|
||||
"""Full page: calendars listing."""
|
||||
content = _calendars_main_panel_html(ctx)
|
||||
hdr = root_header_html(ctx)
|
||||
child = _post_header_html(ctx) + _calendars_header_html(ctx)
|
||||
hdr += render("header-child", inner_html=child)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
|
||||
return full_page(ctx, header_rows_html=root_hdr + post_hdr + admin_hdr, content_html=content)
|
||||
|
||||
|
||||
async def render_calendars_oob(ctx: dict) -> str:
|
||||
"""OOB response: calendars listing."""
|
||||
content = _calendars_main_panel_html(ctx)
|
||||
oobs = _post_header_html(ctx, oob=True)
|
||||
oobs += _oob_header_html("post-header-child", "calendars-header-child",
|
||||
_calendars_header_html(ctx))
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = post_admin_header_html(ctx, slug, oob=True, selected="calendars")
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
@@ -1298,6 +1348,8 @@ async def render_calendar_oob(ctx: dict) -> str:
|
||||
oobs = _post_header_html(ctx, oob=True)
|
||||
oobs += _oob_header_html("post-header-child", "calendar-header-child",
|
||||
_calendar_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"calendar-row", "calendar-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
@@ -1321,6 +1373,9 @@ async def render_day_oob(ctx: dict) -> str:
|
||||
oobs = _calendar_header_html(ctx, oob=True)
|
||||
oobs += _oob_header_html("calendar-header-child", "day-header-child",
|
||||
_day_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"day-row", "day-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
@@ -1331,20 +1386,31 @@ async def render_day_oob(ctx: dict) -> str:
|
||||
async def render_day_admin_page(ctx: dict) -> str:
|
||||
"""Full page: day admin."""
|
||||
content = _day_admin_main_panel_html(ctx)
|
||||
hdr = root_header_html(ctx)
|
||||
child = (_post_header_html(ctx)
|
||||
+ _calendar_header_html(ctx) + _day_header_html(ctx)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
|
||||
child = (admin_hdr + _calendar_header_html(ctx) + _day_header_html(ctx)
|
||||
+ _day_admin_header_html(ctx))
|
||||
hdr += render("header-child", inner_html=child)
|
||||
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
|
||||
|
||||
async def render_day_admin_oob(ctx: dict) -> str:
|
||||
"""OOB response: day admin."""
|
||||
content = _day_admin_main_panel_html(ctx)
|
||||
oobs = _calendar_header_html(ctx, oob=True)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
|
||||
+ _calendar_header_html(ctx, oob=True))
|
||||
oobs += _oob_header_html("day-header-child", "day-admin-header-child",
|
||||
_day_admin_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"day-row", "day-header-child",
|
||||
"day-admin-row", "day-admin-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
@@ -1352,22 +1418,39 @@ async def render_day_admin_oob(ctx: dict) -> str:
|
||||
# Calendar admin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _events_post_admin_header_html(ctx: dict, *, oob: bool = False,
|
||||
selected: str = "") -> str:
|
||||
"""Post-level admin row for events — delegates to shared helper."""
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return post_admin_header_html(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
async def render_calendar_admin_page(ctx: dict) -> str:
|
||||
"""Full page: calendar admin."""
|
||||
content = _calendar_admin_main_panel_html(ctx)
|
||||
hdr = root_header_html(ctx)
|
||||
child = (_post_header_html(ctx)
|
||||
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx))
|
||||
hdr += render("header-child", inner_html=child)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
|
||||
child = admin_hdr + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)
|
||||
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
|
||||
|
||||
async def render_calendar_admin_oob(ctx: dict) -> str:
|
||||
"""OOB response: calendar admin."""
|
||||
content = _calendar_admin_main_panel_html(ctx)
|
||||
oobs = _calendar_header_html(ctx, oob=True)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
|
||||
+ _calendar_header_html(ctx, oob=True))
|
||||
oobs += _oob_header_html("calendar-header-child", "calendar-admin-header-child",
|
||||
_calendar_admin_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"calendar-admin-row", "calendar-admin-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
@@ -1381,10 +1464,13 @@ async def render_slots_page(ctx: dict) -> str:
|
||||
slots = ctx.get("slots") or []
|
||||
calendar = ctx.get("calendar")
|
||||
content = render_slots_table(slots, calendar)
|
||||
hdr = root_header_html(ctx)
|
||||
child = (_post_header_html(ctx)
|
||||
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx))
|
||||
hdr += render("header-child", inner_html=child)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
|
||||
child = admin_hdr + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)
|
||||
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
|
||||
|
||||
@@ -1393,7 +1479,14 @@ async def render_slots_oob(ctx: dict) -> str:
|
||||
slots = ctx.get("slots") or []
|
||||
calendar = ctx.get("calendar")
|
||||
content = render_slots_table(slots, calendar)
|
||||
oobs = _calendar_admin_header_html(ctx, oob=True)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
|
||||
+ _calendar_admin_header_html(ctx, oob=True))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"calendar-admin-row", "calendar-admin-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
@@ -1848,6 +1941,10 @@ async def render_entry_oob(ctx: dict) -> str:
|
||||
oobs = _day_header_html(ctx, oob=True)
|
||||
oobs += _oob_header_html("day-header-child", "entry-header-child",
|
||||
_entry_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"day-row", "day-header-child",
|
||||
"entry-row", "entry-header-child")
|
||||
nav_html = _entry_nav_html(ctx)
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html)
|
||||
|
||||
@@ -2846,11 +2943,14 @@ def _entry_admin_main_panel_html(ctx: dict) -> str:
|
||||
async def render_entry_admin_page(ctx: dict) -> str:
|
||||
"""Full page: entry admin."""
|
||||
content = _entry_admin_main_panel_html(ctx)
|
||||
hdr = root_header_html(ctx)
|
||||
child = (_post_header_html(ctx)
|
||||
+ _calendar_header_html(ctx) + _day_header_html(ctx)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
|
||||
child = (admin_hdr + _calendar_header_html(ctx) + _day_header_html(ctx)
|
||||
+ _entry_header_html(ctx) + _entry_admin_header_html(ctx))
|
||||
hdr += render("header-child", inner_html=child)
|
||||
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
|
||||
nav_html = render("events-admin-placeholder-nav")
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=nav_html)
|
||||
|
||||
@@ -2858,9 +2958,18 @@ async def render_entry_admin_page(ctx: dict) -> str:
|
||||
async def render_entry_admin_oob(ctx: dict) -> str:
|
||||
"""OOB response: entry admin."""
|
||||
content = _entry_admin_main_panel_html(ctx)
|
||||
oobs = _entry_header_html(ctx, oob=True)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
|
||||
+ _entry_header_html(ctx, oob=True))
|
||||
oobs += _oob_header_html("entry-header-child", "entry-admin-header-child",
|
||||
_entry_admin_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"day-row", "day-header-child",
|
||||
"entry-row", "entry-header-child",
|
||||
"entry-admin-row", "entry-admin-header-child")
|
||||
nav_html = render("events-admin-placeholder-nav")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html)
|
||||
|
||||
@@ -2905,11 +3014,14 @@ async def render_slot_page(ctx: dict) -> str:
|
||||
if not slot or not calendar:
|
||||
return ""
|
||||
content = render_slot_main_panel(slot, calendar)
|
||||
hdr = root_header_html(ctx)
|
||||
child = (_post_header_html(ctx)
|
||||
+ _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
root_hdr = root_header_html(ctx)
|
||||
post_hdr = _post_header_html(ctx)
|
||||
admin_hdr = post_admin_header_html(ctx, slug, selected="calendars")
|
||||
child = (admin_hdr + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)
|
||||
+ _slot_header_html(ctx))
|
||||
hdr += render("header-child", inner_html=child)
|
||||
hdr = root_hdr + post_hdr + render("header-child", inner_html=child)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
|
||||
|
||||
@@ -2920,9 +3032,17 @@ async def render_slot_oob(ctx: dict) -> str:
|
||||
if not slot or not calendar:
|
||||
return ""
|
||||
content = render_slot_main_panel(slot, calendar)
|
||||
oobs = _calendar_admin_header_html(ctx, oob=True)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = (post_admin_header_html(ctx, slug, oob=True, selected="calendars")
|
||||
+ _calendar_admin_header_html(ctx, oob=True))
|
||||
oobs += _oob_header_html("calendar-admin-header-child", "slot-header-child",
|
||||
_slot_header_html(ctx))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"calendar-admin-row", "calendar-admin-header-child",
|
||||
"slot-row", "slot-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
|
||||
0
events/tests/__init__.py
Normal file
0
events/tests/__init__.py
Normal file
30
events/tests/conftest.py
Normal file
30
events/tests/conftest.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Events test fixtures — direct module loading to avoid bp __init__ chains."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
|
||||
def _load(name: str, path: str):
|
||||
"""Import a .py file directly, bypassing package __init__ chains."""
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# Ensure events/models is importable as 'models'
|
||||
sys.path.insert(0, "/app/events")
|
||||
|
||||
# Pre-load target modules that would fail via normal package import
|
||||
_load("events.bp.calendar.services.calendar_view",
|
||||
"/app/events/bp/calendar/services/calendar_view.py")
|
||||
_load("events.bp.calendar.services.slots",
|
||||
"/app/events/bp/calendar/services/slots.py")
|
||||
_load("events.bp.calendars.services.calendars",
|
||||
"/app/events/bp/calendars/services/calendars.py")
|
||||
_load("events.bp.calendar_entries.routes",
|
||||
"/app/events/bp/calendar_entries/routes.py")
|
||||
82
events/tests/test_calendar_view.py
Normal file
82
events/tests/test_calendar_view.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Unit tests for calendar view helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from events.bp.calendar.services.calendar_view import add_months, build_calendar_weeks
|
||||
|
||||
|
||||
class TestAddMonths:
|
||||
def test_same_month(self):
|
||||
assert add_months(2025, 6, 0) == (2025, 6)
|
||||
|
||||
def test_forward_one(self):
|
||||
assert add_months(2025, 6, 1) == (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_multi_year_forward(self):
|
||||
assert add_months(2025, 1, 24) == (2027, 1)
|
||||
|
||||
def test_multi_year_backward(self):
|
||||
assert add_months(2025, 3, -15) == (2023, 12)
|
||||
|
||||
def test_negative_large(self):
|
||||
y, m = add_months(2025, 6, -30)
|
||||
assert m >= 1 and m <= 12
|
||||
assert y == 2022 # 2025-06 minus 30 months = 2022-12
|
||||
|
||||
def test_forward_eleven(self):
|
||||
assert add_months(2025, 1, 11) == (2025, 12)
|
||||
|
||||
def test_forward_twelve(self):
|
||||
assert add_months(2025, 1, 12) == (2026, 1)
|
||||
|
||||
|
||||
class TestBuildCalendarWeeks:
|
||||
def test_returns_weeks(self):
|
||||
weeks = build_calendar_weeks(2025, 6)
|
||||
assert len(weeks) >= 4
|
||||
assert len(weeks) <= 6
|
||||
|
||||
def test_seven_days_per_week(self):
|
||||
weeks = build_calendar_weeks(2025, 1)
|
||||
for week in weeks:
|
||||
assert len(week) == 7
|
||||
|
||||
def test_day_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)
|
||||
|
||||
def test_in_month_flag(self):
|
||||
weeks = build_calendar_weeks(2025, 6)
|
||||
# First day of first week might be in May
|
||||
june_days = [
|
||||
d for week in weeks for d in week 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 week in weeks for d in week if d["in_month"]]
|
||||
assert len(feb_days) == 29
|
||||
|
||||
def test_february_non_leap(self):
|
||||
weeks = build_calendar_weeks(2025, 2)
|
||||
feb_days = [d for week in weeks for d in week if d["in_month"]]
|
||||
assert len(feb_days) == 28
|
||||
|
||||
def test_starts_on_monday(self):
|
||||
weeks = build_calendar_weeks(2025, 6)
|
||||
# First day of first week should be a Monday
|
||||
assert weeks[0][0]["date"].weekday() == 0 # Monday
|
||||
57
events/tests/test_entry_cost.py
Normal file
57
events/tests/test_entry_cost.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Unit tests for entry cost calculation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from events.bp.calendar_entries.routes import calculate_entry_cost
|
||||
|
||||
|
||||
def _slot(cost=None, flexible=False, time_start=None, time_end=None):
|
||||
return SimpleNamespace(
|
||||
cost=cost,
|
||||
flexible=flexible,
|
||||
time_start=time_start or time(9, 0),
|
||||
time_end=time_end or time(17, 0),
|
||||
)
|
||||
|
||||
|
||||
class TestCalculateEntryCost:
|
||||
def test_no_cost(self):
|
||||
assert calculate_entry_cost(_slot(cost=None),
|
||||
datetime(2025, 1, 1, 9), datetime(2025, 1, 1, 17)) == Decimal("0")
|
||||
|
||||
def test_fixed_slot(self):
|
||||
result = calculate_entry_cost(_slot(cost=100, flexible=False),
|
||||
datetime(2025, 1, 1, 10), datetime(2025, 1, 1, 12))
|
||||
assert result == Decimal("100")
|
||||
|
||||
def test_flexible_full_duration(self):
|
||||
slot = _slot(cost=80, flexible=True, time_start=time(9, 0), time_end=time(17, 0))
|
||||
# 8 hours slot, booking full 8 hours
|
||||
result = calculate_entry_cost(slot,
|
||||
datetime(2025, 1, 1, 9, 0), datetime(2025, 1, 1, 17, 0))
|
||||
assert result == Decimal("80")
|
||||
|
||||
def test_flexible_half_duration(self):
|
||||
slot = _slot(cost=80, flexible=True, time_start=time(9, 0), time_end=time(17, 0))
|
||||
# 4 hours of 8-hour slot = half
|
||||
result = calculate_entry_cost(slot,
|
||||
datetime(2025, 1, 1, 9, 0), datetime(2025, 1, 1, 13, 0))
|
||||
assert result == Decimal("40")
|
||||
|
||||
def test_flexible_no_time_end(self):
|
||||
slot = _slot(cost=50, flexible=True, time_end=None)
|
||||
result = calculate_entry_cost(slot,
|
||||
datetime(2025, 1, 1, 9), datetime(2025, 1, 1, 12))
|
||||
# When time_end is None, function still calculates based on booking duration
|
||||
assert isinstance(result, Decimal)
|
||||
|
||||
def test_flexible_zero_slot_duration(self):
|
||||
slot = _slot(cost=50, flexible=True, time_start=time(9, 0), time_end=time(9, 0))
|
||||
result = calculate_entry_cost(slot,
|
||||
datetime(2025, 1, 1, 9, 0), datetime(2025, 1, 1, 10, 0))
|
||||
assert result == Decimal("0")
|
||||
59
events/tests/test_slots.py
Normal file
59
events/tests/test_slots.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Unit tests for slot helper functions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from events.bp.calendar.services.slots import _b
|
||||
|
||||
|
||||
class TestBoolParse:
|
||||
def test_true_bool(self):
|
||||
assert _b(True) is True
|
||||
|
||||
def test_false_bool(self):
|
||||
assert _b(False) is False
|
||||
|
||||
def test_string_true(self):
|
||||
assert _b("true") is True
|
||||
|
||||
def test_string_True(self):
|
||||
assert _b("True") is True
|
||||
|
||||
def test_string_1(self):
|
||||
assert _b("1") is True
|
||||
|
||||
def test_string_yes(self):
|
||||
assert _b("yes") is True
|
||||
|
||||
def test_string_y(self):
|
||||
assert _b("y") is True
|
||||
|
||||
def test_string_t(self):
|
||||
assert _b("t") is True
|
||||
|
||||
def test_string_on(self):
|
||||
assert _b("on") is True
|
||||
|
||||
def test_string_false(self):
|
||||
assert _b("false") is False
|
||||
|
||||
def test_string_0(self):
|
||||
assert _b("0") is False
|
||||
|
||||
def test_string_no(self):
|
||||
assert _b("no") is False
|
||||
|
||||
def test_string_off(self):
|
||||
assert _b("off") is False
|
||||
|
||||
def test_string_empty(self):
|
||||
assert _b("") is False
|
||||
|
||||
def test_int_1(self):
|
||||
assert _b(1) is True
|
||||
|
||||
def test_int_0(self):
|
||||
assert _b(0) is False
|
||||
|
||||
def test_random_string(self):
|
||||
assert _b("maybe") is False
|
||||
42
events/tests/test_slugify.py
Normal file
42
events/tests/test_slugify.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Unit tests for events slugify utility."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from events.bp.calendars.services.calendars import slugify
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_basic(self):
|
||||
assert slugify("My Calendar") == "my-calendar"
|
||||
|
||||
def test_unicode(self):
|
||||
assert slugify("café résumé") == "cafe-resume"
|
||||
|
||||
def test_slashes(self):
|
||||
assert slugify("foo/bar") == "foo-bar"
|
||||
|
||||
def test_special_chars(self):
|
||||
assert slugify("hello!!world") == "hello-world"
|
||||
|
||||
def test_collapse_dashes(self):
|
||||
assert slugify("a---b") == "a-b"
|
||||
|
||||
def test_strip_dashes(self):
|
||||
assert slugify("--hello--") == "hello"
|
||||
|
||||
def test_empty_fallback(self):
|
||||
assert slugify("") == "calendar"
|
||||
|
||||
def test_none_fallback(self):
|
||||
assert slugify(None) == "calendar"
|
||||
|
||||
def test_max_len(self):
|
||||
result = slugify("a" * 300, max_len=10)
|
||||
assert len(result) <= 10
|
||||
|
||||
def test_numbers(self):
|
||||
assert slugify("event-2025") == "event-2025"
|
||||
|
||||
def test_already_clean(self):
|
||||
assert slugify("my-event") == "my-event"
|
||||
0
federation/tests/__init__.py
Normal file
0
federation/tests/__init__.py
Normal file
137
federation/tests/test_dto_converters.py
Normal file
137
federation/tests/test_dto_converters.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Unit tests for federation DTO conversion functions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.services.federation_impl import (
|
||||
_actor_to_dto, _activity_to_dto, _follower_to_dto,
|
||||
_remote_actor_to_dto, _remote_post_to_dto,
|
||||
)
|
||||
|
||||
|
||||
def _actor(**kwargs):
|
||||
defaults = {
|
||||
"id": 1, "user_id": 10,
|
||||
"preferred_username": "alice",
|
||||
"public_key_pem": "-----BEGIN PUBLIC KEY-----",
|
||||
"display_name": "Alice", "summary": "Hello",
|
||||
"created_at": datetime(2025, 1, 1),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def _activity(**kwargs):
|
||||
defaults = {
|
||||
"id": 1, "activity_id": "https://example.com/activity/1",
|
||||
"activity_type": "Create", "actor_profile_id": 1,
|
||||
"object_type": "Note", "object_data": {"content": "hi"},
|
||||
"published": datetime(2025, 1, 1), "is_local": True,
|
||||
"source_type": "post", "source_id": 42, "ipfs_cid": None,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def _follower(**kwargs):
|
||||
defaults = {
|
||||
"id": 1, "actor_profile_id": 1,
|
||||
"follower_acct": "bob@remote.com",
|
||||
"follower_inbox": "https://remote.com/inbox",
|
||||
"follower_actor_url": "https://remote.com/users/bob",
|
||||
"created_at": datetime(2025, 1, 1),
|
||||
"app_domain": "federation.rose-ash.com",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def _remote_actor(**kwargs):
|
||||
defaults = {
|
||||
"id": 1, "actor_url": "https://remote.com/users/bob",
|
||||
"inbox_url": "https://remote.com/inbox",
|
||||
"preferred_username": "bob", "domain": "remote.com",
|
||||
"display_name": "Bob", "summary": "Hi",
|
||||
"icon_url": "https://remote.com/avatar.jpg",
|
||||
"shared_inbox_url": None, "public_key_pem": None,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def _remote_post(**kwargs):
|
||||
defaults = {
|
||||
"id": 1, "remote_actor_id": 1,
|
||||
"object_id": "https://remote.com/note/1",
|
||||
"content": "<p>Hello</p>", "summary": None,
|
||||
"url": "https://remote.com/note/1",
|
||||
"attachment_data": [{"type": "Image", "url": "img.jpg"}],
|
||||
"tag_data": [{"type": "Hashtag", "name": "#test"}],
|
||||
"published": datetime(2025, 1, 1),
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
class TestActorToDto:
|
||||
@patch("shared.services.federation_impl._domain", return_value="fed.example.com")
|
||||
def test_basic(self, mock_domain):
|
||||
dto = _actor_to_dto(_actor())
|
||||
assert dto.preferred_username == "alice"
|
||||
assert dto.inbox_url == "https://fed.example.com/users/alice/inbox"
|
||||
assert dto.outbox_url == "https://fed.example.com/users/alice/outbox"
|
||||
assert dto.user_id == 10
|
||||
assert dto.display_name == "Alice"
|
||||
|
||||
|
||||
class TestActivityToDto:
|
||||
def test_fields(self):
|
||||
dto = _activity_to_dto(_activity())
|
||||
assert dto.activity_type == "Create"
|
||||
assert dto.object_type == "Note"
|
||||
assert dto.is_local is True
|
||||
assert dto.ipfs_cid is None
|
||||
|
||||
|
||||
class TestFollowerToDto:
|
||||
def test_fields(self):
|
||||
dto = _follower_to_dto(_follower())
|
||||
assert dto.follower_acct == "bob@remote.com"
|
||||
assert dto.app_domain == "federation.rose-ash.com"
|
||||
|
||||
|
||||
class TestRemoteActorToDto:
|
||||
def test_fields(self):
|
||||
dto = _remote_actor_to_dto(_remote_actor())
|
||||
assert dto.preferred_username == "bob"
|
||||
assert dto.domain == "remote.com"
|
||||
assert dto.icon_url == "https://remote.com/avatar.jpg"
|
||||
|
||||
|
||||
class TestRemotePostToDto:
|
||||
def test_with_actor(self):
|
||||
actor = _remote_actor()
|
||||
dto = _remote_post_to_dto(_remote_post(), actor=actor)
|
||||
assert dto.content == "<p>Hello</p>"
|
||||
assert dto.actor is not None
|
||||
assert dto.actor.preferred_username == "bob"
|
||||
|
||||
def test_without_actor(self):
|
||||
dto = _remote_post_to_dto(_remote_post(), actor=None)
|
||||
assert dto.actor is None
|
||||
|
||||
def test_none_content_becomes_empty(self):
|
||||
dto = _remote_post_to_dto(_remote_post(content=None))
|
||||
assert dto.content == ""
|
||||
|
||||
def test_none_attachments_becomes_list(self):
|
||||
dto = _remote_post_to_dto(_remote_post(attachment_data=None))
|
||||
assert dto.attachments == []
|
||||
|
||||
def test_none_tags_becomes_list(self):
|
||||
dto = _remote_post_to_dto(_remote_post(tag_data=None))
|
||||
assert dto.tags == []
|
||||
70
federation/tests/test_identity.py
Normal file
70
federation/tests/test_identity.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Unit tests for federation identity validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from federation.bp.identity.routes import USERNAME_RE, RESERVED
|
||||
|
||||
|
||||
class TestUsernameRegex:
|
||||
def test_valid_simple(self):
|
||||
assert USERNAME_RE.match("alice")
|
||||
|
||||
def test_valid_with_numbers(self):
|
||||
assert USERNAME_RE.match("user42")
|
||||
|
||||
def test_valid_with_underscore(self):
|
||||
assert USERNAME_RE.match("alice_bob")
|
||||
|
||||
def test_min_length(self):
|
||||
assert USERNAME_RE.match("abc")
|
||||
|
||||
def test_too_short(self):
|
||||
assert not USERNAME_RE.match("ab")
|
||||
|
||||
def test_single_char(self):
|
||||
assert not USERNAME_RE.match("a")
|
||||
|
||||
def test_max_length(self):
|
||||
assert USERNAME_RE.match("a" * 32)
|
||||
|
||||
def test_too_long(self):
|
||||
assert not USERNAME_RE.match("a" * 33)
|
||||
|
||||
def test_starts_with_digit(self):
|
||||
assert not USERNAME_RE.match("1abc")
|
||||
|
||||
def test_starts_with_underscore(self):
|
||||
assert not USERNAME_RE.match("_abc")
|
||||
|
||||
def test_uppercase_rejected(self):
|
||||
assert not USERNAME_RE.match("Alice")
|
||||
|
||||
def test_hyphen_rejected(self):
|
||||
assert not USERNAME_RE.match("alice-bob")
|
||||
|
||||
def test_spaces_rejected(self):
|
||||
assert not USERNAME_RE.match("alice bob")
|
||||
|
||||
def test_empty_rejected(self):
|
||||
assert not USERNAME_RE.match("")
|
||||
|
||||
|
||||
class TestReservedUsernames:
|
||||
def test_admin_reserved(self):
|
||||
assert "admin" in RESERVED
|
||||
|
||||
def test_root_reserved(self):
|
||||
assert "root" in RESERVED
|
||||
|
||||
def test_api_reserved(self):
|
||||
assert "api" in RESERVED
|
||||
|
||||
def test_inbox_reserved(self):
|
||||
assert "inbox" in RESERVED
|
||||
|
||||
def test_normal_name_not_reserved(self):
|
||||
assert "alice" not in RESERVED
|
||||
|
||||
def test_at_least_20_reserved(self):
|
||||
assert len(RESERVED) >= 20
|
||||
0
likes/tests/__init__.py
Normal file
0
likes/tests/__init__.py
Normal file
76
likes/tests/test_data_guards.py
Normal file
76
likes/tests/test_data_guards.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Unit tests for likes data endpoint guard logic.
|
||||
|
||||
The actual data handlers require Quart request context and DB,
|
||||
but we can test the guard validation logic patterns used throughout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestLikedGuardPatterns:
|
||||
"""Test the validation patterns used in likes data endpoints.
|
||||
|
||||
The handlers return early with empty/false results when required
|
||||
params are missing. These tests verify those conditions.
|
||||
"""
|
||||
|
||||
def test_is_liked_requires_user_id(self):
|
||||
"""Without user_id, is_liked returns {'liked': False}."""
|
||||
# Simulates: user_id = None, target_type = "product"
|
||||
user_id = None
|
||||
target_type = "product"
|
||||
if not user_id or not target_type:
|
||||
result = {"liked": False}
|
||||
else:
|
||||
result = {"liked": True} # would check DB
|
||||
assert result == {"liked": False}
|
||||
|
||||
def test_is_liked_requires_target_type(self):
|
||||
user_id = 1
|
||||
target_type = ""
|
||||
if not user_id or not target_type:
|
||||
result = {"liked": False}
|
||||
else:
|
||||
result = {"liked": True}
|
||||
assert result == {"liked": False}
|
||||
|
||||
def test_is_liked_requires_slug_or_id(self):
|
||||
"""Without target_slug or target_id, returns {'liked': False}."""
|
||||
target_slug = None
|
||||
target_id = None
|
||||
if target_slug is None and target_id is None:
|
||||
result = {"liked": False}
|
||||
else:
|
||||
result = {"liked": True}
|
||||
assert result == {"liked": False}
|
||||
|
||||
def test_liked_slugs_empty_without_user_id(self):
|
||||
user_id = None
|
||||
target_type = "product"
|
||||
if not user_id or not target_type:
|
||||
result = []
|
||||
else:
|
||||
result = ["slug-1"]
|
||||
assert result == []
|
||||
|
||||
def test_liked_ids_empty_without_target_type(self):
|
||||
user_id = 1
|
||||
target_type = ""
|
||||
if not user_id or not target_type:
|
||||
result = []
|
||||
else:
|
||||
result = [1, 2]
|
||||
assert result == []
|
||||
|
||||
def test_all_params_present(self):
|
||||
user_id = 1
|
||||
target_type = "product"
|
||||
target_slug = "my-product"
|
||||
if not user_id or not target_type:
|
||||
result = {"liked": False}
|
||||
elif target_slug is not None:
|
||||
result = {"liked": True} # would check DB
|
||||
else:
|
||||
result = {"liked": False}
|
||||
assert result == {"liked": True}
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy import select
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
from shared.config import config
|
||||
|
||||
from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments, register_actions, register_data
|
||||
from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_fragments, register_actions, register_data
|
||||
|
||||
|
||||
async def market_context() -> dict:
|
||||
@@ -111,6 +111,12 @@ def create_app() -> "Quart":
|
||||
url_prefix="/<slug>",
|
||||
)
|
||||
|
||||
# Page admin: /<slug>/admin/ — post-level admin for markets
|
||||
app.register_blueprint(
|
||||
register_page_admin(),
|
||||
url_prefix="/<slug>/admin",
|
||||
)
|
||||
|
||||
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/
|
||||
app.register_blueprint(
|
||||
register_market_bp(
|
||||
|
||||
@@ -2,6 +2,7 @@ from .market.routes import register as register_market_bp
|
||||
from .product.routes import register as register_product
|
||||
from .all_markets.routes import register as register_all_markets
|
||||
from .page_markets.routes import register as register_page_markets
|
||||
from .page_admin.routes import register as register_page_admin
|
||||
from .fragments import register_fragments
|
||||
from .actions import register_actions
|
||||
from .data import register_data
|
||||
|
||||
0
market/bp/page_admin/__init__.py
Normal file
0
market/bp/page_admin/__init__.py
Normal file
25
market/bp/page_admin/routes.py
Normal file
25
market/bp/page_admin/routes.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import make_response, Blueprint
|
||||
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("page_admin", __name__)
|
||||
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin(**kwargs):
|
||||
from shared.sexp.page import get_template_context
|
||||
from sexp.sexp_components import render_page_admin_page, render_page_admin_oob
|
||||
|
||||
tctx = await get_template_context()
|
||||
if not is_htmx_request():
|
||||
html = await render_page_admin_page(tctx)
|
||||
else:
|
||||
html = await render_page_admin_oob(tctx)
|
||||
return await make_response(html)
|
||||
|
||||
return bp
|
||||
@@ -15,6 +15,7 @@ from shared.sexp.jinja_bridge import render, load_service_components
|
||||
from shared.sexp.helpers import (
|
||||
call_url, get_asset_url, root_header_html,
|
||||
post_header_html as _post_header_html,
|
||||
post_admin_header_html,
|
||||
oob_header_html as _oob_header_html,
|
||||
search_mobile_html, search_desktop_html,
|
||||
full_page, oob_page,
|
||||
@@ -24,6 +25,25 @@ from shared.sexp.helpers import (
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OOB orphan cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MARKET_DEEP_IDS = [
|
||||
"product-admin-row", "product-admin-header-child",
|
||||
"product-row", "product-header-child",
|
||||
"market-admin-row", "market-admin-header-child",
|
||||
"market-row", "market-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
]
|
||||
|
||||
|
||||
def _clear_deeper_oob(*keep_ids: str) -> str:
|
||||
"""Clear all market header rows/children NOT in keep_ids."""
|
||||
to_clear = [i for i in _MARKET_DEEP_IDS if i not in keep_ids]
|
||||
return "".join(f'<div id="{i}" hx-swap-oob="outerHTML"></div>' for i in to_clear)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1284,6 +1304,8 @@ async def render_market_home_oob(ctx: dict) -> str:
|
||||
oobs = _oob_header_html("post-header-child", "market-header-child",
|
||||
_market_header_html(ctx))
|
||||
oobs += _post_header_html(ctx, oob=True)
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child")
|
||||
menu = _mobile_nav_panel_html(ctx)
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu)
|
||||
|
||||
@@ -1333,6 +1355,8 @@ async def render_browse_oob(ctx: dict) -> str:
|
||||
oobs = _oob_header_html("post-header-child", "market-header-child",
|
||||
_market_header_html(ctx))
|
||||
oobs += _post_header_html(ctx, oob=True)
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child")
|
||||
menu = _mobile_nav_panel_html(ctx)
|
||||
filter_html = _mobile_filter_summary_html(ctx)
|
||||
aside_html = _desktop_filter_html(ctx)
|
||||
@@ -1368,6 +1392,9 @@ async def render_product_oob(ctx: dict, d: dict) -> str:
|
||||
oobs = _market_header_html(ctx, oob=True)
|
||||
oobs += _oob_header_html("market-header-child", "product-header-child",
|
||||
_product_header_html(ctx, d))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child",
|
||||
"product-row", "product-header-child")
|
||||
menu = _mobile_nav_panel_html(ctx)
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu)
|
||||
|
||||
@@ -1394,6 +1421,10 @@ async def render_product_admin_oob(ctx: dict, d: dict) -> str:
|
||||
oobs = _product_header_html(ctx, d, oob=True)
|
||||
oobs += _oob_header_html("product-header-child", "product-admin-header-child",
|
||||
_product_admin_header_html(ctx, d))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child",
|
||||
"product-row", "product-header-child",
|
||||
"product-admin-row", "product-admin-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
@@ -1420,7 +1451,7 @@ async def render_market_admin_page(ctx: dict) -> str:
|
||||
content = "market admin"
|
||||
|
||||
hdr = root_header_html(ctx)
|
||||
child = _post_header_html(ctx) + _market_header_html(ctx) + _market_admin_header_html(ctx)
|
||||
child = _post_header_html(ctx) + _market_header_html(ctx) + _market_admin_header_html(ctx, selected="markets")
|
||||
hdr += render("header-child", inner_html=child)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
|
||||
@@ -1431,21 +1462,42 @@ async def render_market_admin_oob(ctx: dict) -> str:
|
||||
|
||||
oobs = _market_header_html(ctx, oob=True)
|
||||
oobs += _oob_header_html("market-header-child", "market-admin-header-child",
|
||||
_market_admin_header_html(ctx))
|
||||
_market_admin_header_html(ctx, selected="markets"))
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child",
|
||||
"market-admin-row", "market-admin-header-child")
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
def _market_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build market admin header row."""
|
||||
from quart import url_for
|
||||
def _market_admin_header_html(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
|
||||
"""Build market admin header row — delegates to shared helper."""
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return post_admin_header_html(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
link_href = url_for("market.admin.admin")
|
||||
return render(
|
||||
"menu-row",
|
||||
id="market-admin-row", level=3,
|
||||
link_href=link_href, link_label="admin", icon="fa fa-cog",
|
||||
child_id="market-admin-header-child", oob=oob,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page admin (/<slug>/admin/) — post-level admin for markets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def render_page_admin_page(ctx: dict) -> str:
|
||||
"""Full page: page-level market admin."""
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
admin_hdr = post_admin_header_html(ctx, slug, selected="markets")
|
||||
hdr = root_header_html(ctx)
|
||||
child = _post_header_html(ctx) + admin_hdr
|
||||
hdr += render("header-child", inner_html=child)
|
||||
content = '<div id="main-panel"><div class="p-4 text-stone-500">Market admin</div></div>'
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
|
||||
|
||||
async def render_page_admin_oob(ctx: dict) -> str:
|
||||
"""OOB response: page-level market admin."""
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
oobs = post_admin_header_html(ctx, slug, oob=True, selected="markets")
|
||||
oobs += _clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child")
|
||||
content = '<div id="main-panel"><div class="p-4 text-stone-500">Market admin</div></div>'
|
||||
return oob_page(ctx, oobs_html=oobs, content_html=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
0
market/tests/__init__.py
Normal file
0
market/tests/__init__.py
Normal file
21
market/tests/conftest.py
Normal file
21
market/tests/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Market test fixtures — direct module loading to avoid bp __init__ chains."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
|
||||
def _load(name: str, path: str):
|
||||
"""Import a .py file directly, bypassing package __init__ chains."""
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
sys.path.insert(0, "/app/market")
|
||||
|
||||
_load("market.scrape.listings", "/app/market/scrape/listings.py")
|
||||
78
market/tests/test_listings.py
Normal file
78
market/tests/test_listings.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Unit tests for listings parsing utilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from market.scrape.listings import (
|
||||
parse_total_pages_from_text,
|
||||
_first_from_srcset,
|
||||
_dedupe_preserve_order_by,
|
||||
_filename_key,
|
||||
)
|
||||
|
||||
|
||||
class TestParseTotalPages:
|
||||
def test_standard(self):
|
||||
assert parse_total_pages_from_text("Showing 36 of 120") == 4
|
||||
|
||||
def test_exact_fit(self):
|
||||
assert parse_total_pages_from_text("Showing 36 of 36") == 1
|
||||
|
||||
def test_partial_page(self):
|
||||
assert parse_total_pages_from_text("Showing 36 of 37") == 2
|
||||
|
||||
def test_no_match(self):
|
||||
assert parse_total_pages_from_text("no data") is None
|
||||
|
||||
def test_case_insensitive(self):
|
||||
# shown=12 is in {12,24,36} so per_page=36, ceil(60/36)=2
|
||||
assert parse_total_pages_from_text("showing 12 of 60") == 2
|
||||
|
||||
def test_small_shown(self):
|
||||
# shown=10, not in {12,24,36}, so per_page=10
|
||||
assert parse_total_pages_from_text("Showing 10 of 100") == 10
|
||||
|
||||
|
||||
class TestFirstFromSrcset:
|
||||
def test_single_entry(self):
|
||||
assert _first_from_srcset("img.jpg 1x") == "img.jpg"
|
||||
|
||||
def test_multiple_entries(self):
|
||||
assert _first_from_srcset("a.jpg 1x, b.jpg 2x") == "a.jpg"
|
||||
|
||||
def test_empty(self):
|
||||
assert _first_from_srcset("") is None
|
||||
|
||||
def test_none(self):
|
||||
assert _first_from_srcset(None) is None
|
||||
|
||||
|
||||
class TestDedupePreserveOrder:
|
||||
def test_no_dupes(self):
|
||||
result = _dedupe_preserve_order_by(["a", "b", "c"], key=str)
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_removes_dupes(self):
|
||||
result = _dedupe_preserve_order_by(["a", "b", "a"], key=str)
|
||||
assert result == ["a", "b"]
|
||||
|
||||
def test_empty_strings_skipped(self):
|
||||
result = _dedupe_preserve_order_by(["a", "", "b"], key=str)
|
||||
assert result == ["a", "b"]
|
||||
|
||||
def test_preserves_order(self):
|
||||
result = _dedupe_preserve_order_by(["c", "b", "a", "b"], key=str)
|
||||
assert result == ["c", "b", "a"]
|
||||
|
||||
|
||||
class TestFilenameKey:
|
||||
def test_basic(self):
|
||||
assert _filename_key("https://img.com/photos/pic.jpg") == "img.com:pic.jpg"
|
||||
|
||||
def test_trailing_slash(self):
|
||||
k = _filename_key("https://img.com/photos/")
|
||||
assert k == "img.com:photos"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
k = _filename_key("https://IMG.COM/PIC.JPG")
|
||||
assert k == "img.com:pic.jpg"
|
||||
96
market/tests/test_price.py
Normal file
96
market/tests/test_price.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Unit tests for price parsing utilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from market.scrape.product.helpers.price import parse_price, parse_case_size
|
||||
|
||||
|
||||
class TestParsePrice:
|
||||
def test_gbp(self):
|
||||
val, cur, raw = parse_price("£30.50")
|
||||
assert val == 30.5
|
||||
assert cur == "GBP"
|
||||
|
||||
def test_eur(self):
|
||||
val, cur, raw = parse_price("€1,234.00")
|
||||
assert val == 1234.0
|
||||
assert cur == "EUR"
|
||||
|
||||
def test_usd(self):
|
||||
val, cur, raw = parse_price("$9.99")
|
||||
assert val == 9.99
|
||||
assert cur == "USD"
|
||||
|
||||
def test_no_symbol(self):
|
||||
val, cur, raw = parse_price("42.50")
|
||||
assert val == 42.5
|
||||
assert cur is None
|
||||
|
||||
def test_no_match(self):
|
||||
val, cur, raw = parse_price("no price here")
|
||||
assert val is None
|
||||
assert cur is None
|
||||
|
||||
def test_empty_string(self):
|
||||
val, cur, raw = parse_price("")
|
||||
assert val is None
|
||||
assert raw == ""
|
||||
|
||||
def test_none_input(self):
|
||||
val, cur, raw = parse_price(None)
|
||||
assert val is None
|
||||
assert raw == ""
|
||||
|
||||
def test_thousands_comma_stripped(self):
|
||||
val, cur, raw = parse_price("£1,000.50")
|
||||
assert val == 1000.5
|
||||
|
||||
def test_whitespace_around_symbol(self):
|
||||
val, cur, raw = parse_price("£ 5.00")
|
||||
assert val == 5.0
|
||||
assert cur == "GBP"
|
||||
|
||||
def test_raw_preserved(self):
|
||||
_, _, raw = parse_price(" £10.00 ")
|
||||
assert raw == "£10.00"
|
||||
|
||||
|
||||
class TestParseCaseSize:
|
||||
def test_standard(self):
|
||||
count, qty, unit, _ = parse_case_size("6 x 500g")
|
||||
assert count == 6
|
||||
assert qty == 500.0
|
||||
assert unit == "g"
|
||||
|
||||
def test_no_space(self):
|
||||
count, qty, unit, _ = parse_case_size("12x1L")
|
||||
assert count == 12
|
||||
assert qty == 1.0
|
||||
assert unit == "L"
|
||||
|
||||
def test_multiplication_sign(self):
|
||||
count, qty, unit, _ = parse_case_size("24 × 330 ml")
|
||||
assert count == 24
|
||||
assert qty == 330.0
|
||||
assert unit == "ml"
|
||||
|
||||
def test_uppercase_x(self):
|
||||
count, qty, unit, _ = parse_case_size("6X500g")
|
||||
assert count == 6
|
||||
|
||||
def test_no_match(self):
|
||||
count, qty, unit, raw = parse_case_size("just text")
|
||||
assert count is None
|
||||
assert qty is None
|
||||
assert unit is None
|
||||
|
||||
def test_empty(self):
|
||||
count, qty, unit, raw = parse_case_size("")
|
||||
assert count is None
|
||||
assert raw == ""
|
||||
|
||||
def test_none_input(self):
|
||||
count, _, _, raw = parse_case_size(None)
|
||||
assert count is None
|
||||
assert raw == ""
|
||||
58
market/tests/test_registry.py
Normal file
58
market/tests/test_registry.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Unit tests for product registry utilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from market.scrape.product.registry import merge_missing
|
||||
|
||||
|
||||
class TestMergeMissing:
|
||||
def test_fills_empty_dict(self):
|
||||
dst = {}
|
||||
merge_missing(dst, {"a": 1, "b": "hello"})
|
||||
assert dst == {"a": 1, "b": "hello"}
|
||||
|
||||
def test_existing_value_not_overwritten(self):
|
||||
dst = {"a": "original"}
|
||||
merge_missing(dst, {"a": "new"})
|
||||
assert dst["a"] == "original"
|
||||
|
||||
def test_none_overwritten(self):
|
||||
dst = {"a": None}
|
||||
merge_missing(dst, {"a": "filled"})
|
||||
assert dst["a"] == "filled"
|
||||
|
||||
def test_empty_string_overwritten(self):
|
||||
dst = {"a": ""}
|
||||
merge_missing(dst, {"a": "filled"})
|
||||
assert dst["a"] == "filled"
|
||||
|
||||
def test_empty_list_overwritten(self):
|
||||
dst = {"a": []}
|
||||
merge_missing(dst, {"a": [1, 2]})
|
||||
assert dst["a"] == [1, 2]
|
||||
|
||||
def test_empty_dict_overwritten(self):
|
||||
dst = {"a": {}}
|
||||
merge_missing(dst, {"a": {"key": "val"}})
|
||||
assert dst["a"] == {"key": "val"}
|
||||
|
||||
def test_zero_not_overwritten(self):
|
||||
dst = {"a": 0}
|
||||
merge_missing(dst, {"a": 42})
|
||||
assert dst["a"] == 0
|
||||
|
||||
def test_false_not_overwritten(self):
|
||||
dst = {"a": False}
|
||||
merge_missing(dst, {"a": True})
|
||||
assert dst["a"] is False
|
||||
|
||||
def test_none_src(self):
|
||||
dst = {"a": 1}
|
||||
merge_missing(dst, None)
|
||||
assert dst == {"a": 1}
|
||||
|
||||
def test_new_keys_added(self):
|
||||
dst = {"a": 1}
|
||||
merge_missing(dst, {"b": 2})
|
||||
assert dst == {"a": 1, "b": 2}
|
||||
45
market/tests/test_slugs.py
Normal file
45
market/tests/test_slugs.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Unit tests for market slug helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from market.bp.browse.services.slugs import (
|
||||
product_slug_from_href, canonical_html_slug,
|
||||
)
|
||||
|
||||
|
||||
class TestProductSlugFromHref:
|
||||
def test_html_extension(self):
|
||||
result = product_slug_from_href("https://site.com/foo/bar-thing.html")
|
||||
assert result == "bar-thing-html"
|
||||
|
||||
def test_htm_extension(self):
|
||||
result = product_slug_from_href("https://site.com/products/widget.htm")
|
||||
assert result == "widget-html"
|
||||
|
||||
def test_no_extension(self):
|
||||
result = product_slug_from_href("https://site.com/item/cool-product")
|
||||
assert result == "cool-product-html"
|
||||
|
||||
def test_empty_path(self):
|
||||
result = product_slug_from_href("https://site.com/")
|
||||
assert result == ""
|
||||
|
||||
def test_already_has_html_suffix_in_slug(self):
|
||||
result = product_slug_from_href("https://site.com/prod/item-html.html")
|
||||
assert result == "item-html"
|
||||
|
||||
|
||||
class TestCanonicalHtmlSlug:
|
||||
def test_adds_html_suffix(self):
|
||||
assert canonical_html_slug("product-name") == "product-name-html"
|
||||
|
||||
def test_idempotent(self):
|
||||
assert canonical_html_slug("product-name-html") == "product-name-html"
|
||||
|
||||
def test_double_html_kept(self):
|
||||
# canonical_html_slug only appends -html if not already present
|
||||
assert canonical_html_slug("product-name-html-html") == "product-name-html-html"
|
||||
|
||||
def test_strips_htm(self):
|
||||
assert canonical_html_slug("product-name-htm") == "product-name-html"
|
||||
0
orders/tests/__init__.py
Normal file
0
orders/tests/__init__.py
Normal file
32
orders/tests/test_sexp_helpers.py
Normal file
32
orders/tests/test_sexp_helpers.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Unit tests for orders sexp component helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orders.sexp.sexp_components import _status_pill_cls
|
||||
|
||||
|
||||
class TestStatusPillCls:
|
||||
def test_paid(self):
|
||||
result = _status_pill_cls("paid")
|
||||
assert "emerald" in result
|
||||
|
||||
def test_Paid_uppercase(self):
|
||||
result = _status_pill_cls("Paid")
|
||||
assert "emerald" in result
|
||||
|
||||
def test_failed(self):
|
||||
result = _status_pill_cls("failed")
|
||||
assert "rose" in result
|
||||
|
||||
def test_cancelled(self):
|
||||
result = _status_pill_cls("cancelled")
|
||||
assert "rose" in result
|
||||
|
||||
def test_pending(self):
|
||||
result = _status_pill_cls("pending")
|
||||
assert "stone" in result
|
||||
|
||||
def test_unknown(self):
|
||||
result = _status_pill_cls("refunded")
|
||||
assert "stone" in result
|
||||
0
relations/tests/__init__.py
Normal file
0
relations/tests/__init__.py
Normal file
66
relations/tests/test_serialize.py
Normal file
66
relations/tests/test_serialize.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Unit tests for relations serialization."""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from relations.bp.data.routes import _serialize_rel
|
||||
|
||||
|
||||
class TestSerializeRel:
|
||||
def test_all_fields(self):
|
||||
rel = SimpleNamespace(
|
||||
id=1,
|
||||
parent_type="page",
|
||||
parent_id=100,
|
||||
child_type="calendar",
|
||||
child_id=200,
|
||||
sort_order=0,
|
||||
label="Main Calendar",
|
||||
relation_type="container",
|
||||
metadata_={"key": "val"},
|
||||
)
|
||||
result = _serialize_rel(rel)
|
||||
assert result == {
|
||||
"id": 1,
|
||||
"parent_type": "page",
|
||||
"parent_id": 100,
|
||||
"child_type": "calendar",
|
||||
"child_id": 200,
|
||||
"sort_order": 0,
|
||||
"label": "Main Calendar",
|
||||
"relation_type": "container",
|
||||
"metadata": {"key": "val"},
|
||||
}
|
||||
|
||||
def test_none_optionals(self):
|
||||
rel = SimpleNamespace(
|
||||
id=2,
|
||||
parent_type="post",
|
||||
parent_id=1,
|
||||
child_type="market",
|
||||
child_id=1,
|
||||
sort_order=1,
|
||||
label=None,
|
||||
relation_type=None,
|
||||
metadata_=None,
|
||||
)
|
||||
result = _serialize_rel(rel)
|
||||
assert result["label"] is None
|
||||
assert result["relation_type"] is None
|
||||
assert result["metadata"] is None
|
||||
|
||||
def test_dict_structure(self):
|
||||
rel = SimpleNamespace(
|
||||
id=3, parent_type="a", parent_id=1,
|
||||
child_type="b", child_id=2,
|
||||
sort_order=0, label="", relation_type="link",
|
||||
metadata_={},
|
||||
)
|
||||
result = _serialize_rel(rel)
|
||||
assert set(result.keys()) == {
|
||||
"id", "parent_type", "parent_id",
|
||||
"child_type", "child_id", "sort_order",
|
||||
"label", "relation_type", "metadata",
|
||||
}
|
||||
@@ -14,6 +14,7 @@ from quart import request, g, current_app
|
||||
from shared.config import config
|
||||
from shared.utils import host_url
|
||||
from shared.browser.app.utils import current_route_relative_path
|
||||
from shared.infrastructure.urls import blog_url, market_url, cart_url, events_url
|
||||
|
||||
|
||||
def _qs_filter_fn():
|
||||
@@ -98,7 +99,13 @@ async def base_context() -> dict:
|
||||
"qs_filter": _qs_filter_fn(),
|
||||
"print": print,
|
||||
"base_url": base_url,
|
||||
"app_label": current_app.name,
|
||||
"base_title": config()["title"],
|
||||
"hx_select": hx_select,
|
||||
"hx_select_search": hx_select_search,
|
||||
"blog_url": blog_url,
|
||||
"market_url": market_url,
|
||||
"cart_url": cart_url,
|
||||
"events_url": events_url,
|
||||
"rights": getattr(g, "rights", {}),
|
||||
}
|
||||
|
||||
@@ -114,11 +114,20 @@ def create_base_app(
|
||||
setup_sexp_bridge(app)
|
||||
load_shared_components()
|
||||
load_relation_registry()
|
||||
|
||||
# Dev-mode: auto-reload sexp templates when files change on disk
|
||||
if os.getenv("RELOAD") == "true":
|
||||
from shared.sexp.jinja_bridge import reload_if_changed
|
||||
|
||||
@app.before_request
|
||||
async def _sexp_hot_reload():
|
||||
reload_if_changed()
|
||||
errors(app)
|
||||
|
||||
# Auto-register OAuth client blueprint for non-account apps
|
||||
# (account is the OAuth authorization server)
|
||||
if name != "account":
|
||||
# (account is the OAuth authorization server; test is a public dashboard)
|
||||
_NO_OAUTH = {"account", "test"}
|
||||
if name not in _NO_OAUTH:
|
||||
from shared.infrastructure.oauth import create_oauth_blueprint
|
||||
app.register_blueprint(create_oauth_blueprint(name))
|
||||
|
||||
@@ -165,7 +174,7 @@ def create_base_app(
|
||||
|
||||
# Auth state check via grant verification + silent OAuth handshake
|
||||
# MUST run before _load_user so stale sessions are cleared first
|
||||
if name != "account":
|
||||
if name not in _NO_OAUTH:
|
||||
@app.before_request
|
||||
async def _check_auth_state():
|
||||
from quart import session as qs
|
||||
|
||||
@@ -9,10 +9,11 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .jinja_bridge import load_sexp_dir
|
||||
from .jinja_bridge import load_sexp_dir, watch_sexp_dir
|
||||
|
||||
|
||||
def load_shared_components() -> None:
|
||||
"""Register all shared s-expression components."""
|
||||
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||
load_sexp_dir(templates_dir)
|
||||
watch_sexp_dir(templates_dir)
|
||||
|
||||
@@ -8,6 +8,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from markupsafe import escape
|
||||
|
||||
from .jinja_bridge import render
|
||||
from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP
|
||||
|
||||
@@ -31,14 +33,20 @@ def get_asset_url(ctx: dict) -> str:
|
||||
|
||||
def root_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the root header row HTML."""
|
||||
rights = ctx.get("rights") or {}
|
||||
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||
settings_url = call_url(ctx, "blog_url", "/settings/") if is_admin else ""
|
||||
return render(
|
||||
"header-row",
|
||||
cart_mini_html=ctx.get("cart_mini_html", ""),
|
||||
blog_url=call_url(ctx, "blog_url", ""),
|
||||
site_title=ctx.get("base_title", ""),
|
||||
app_label=ctx.get("app_label", ""),
|
||||
nav_tree_html=ctx.get("nav_tree_html", ""),
|
||||
auth_menu_html=ctx.get("auth_menu_html", ""),
|
||||
nav_panel_html=ctx.get("nav_panel_html", ""),
|
||||
settings_url=settings_url,
|
||||
is_admin=is_admin,
|
||||
oob=oob,
|
||||
)
|
||||
|
||||
@@ -86,9 +94,31 @@ def post_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
|
||||
container_nav = ctx.get("container_nav_html", "")
|
||||
if container_nav:
|
||||
nav_parts.append(container_nav)
|
||||
nav_parts.append(
|
||||
'<div class="flex flex-col sm:flex-row sm:items-center gap-2'
|
||||
' border-r border-stone-200 mr-2 sm:max-w-2xl"'
|
||||
' id="entries-calendars-nav-wrapper">'
|
||||
f'{container_nav}</div>'
|
||||
)
|
||||
|
||||
# Admin cog — external link to blog admin (generic across all services)
|
||||
admin_nav = ctx.get("post_admin_nav_html", "")
|
||||
if not admin_nav:
|
||||
rights = ctx.get("rights") or {}
|
||||
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
|
||||
if has_admin and slug:
|
||||
from quart import request
|
||||
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
|
||||
is_admin_page = "/admin" in request.path
|
||||
sel_cls = "!bg-stone-500 !text-white" if is_admin_page else ""
|
||||
base_cls = ("justify-center cursor-pointer flex flex-row"
|
||||
" items-center gap-2 rounded bg-stone-200 text-black p-3")
|
||||
admin_nav = (
|
||||
f'<div class="relative nav-group">'
|
||||
f'<a href="{escape(admin_href)}"'
|
||||
f' class="{base_cls} {sel_cls}">'
|
||||
f'<i class="fa fa-cog" aria-hidden="true"></i></a></div>'
|
||||
)
|
||||
if admin_nav:
|
||||
nav_parts.append(admin_nav)
|
||||
|
||||
@@ -103,6 +133,62 @@ def post_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
)
|
||||
|
||||
|
||||
def post_admin_header_html(ctx: dict, slug: str, *, oob: bool = False,
|
||||
selected: str = "", admin_href: str = "") -> str:
|
||||
"""Shared post admin header row with unified nav across all services.
|
||||
|
||||
Shows: calendars | markets | payments | entries | data | edit | settings
|
||||
All links are external (cross-service). The *selected* item is
|
||||
highlighted on the nav and shown in white next to the admin label.
|
||||
"""
|
||||
# Label: shield icon + "admin" + optional selected sub-page in white
|
||||
label_html = '<i class="fa fa-shield-halved" aria-hidden="true"></i> admin'
|
||||
if selected:
|
||||
label_html += f' <span class="text-white">{escape(selected)}</span>'
|
||||
|
||||
# Nav items — all external links to the appropriate service
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
base_cls = ("justify-center cursor-pointer flex flex-row items-center"
|
||||
" gap-2 rounded bg-stone-200 text-black p-3")
|
||||
selected_cls = ("justify-center cursor-pointer flex flex-row items-center"
|
||||
" gap-2 rounded !bg-stone-500 !text-white p-3")
|
||||
nav_parts: list[str] = []
|
||||
items = [
|
||||
("events_url", f"/{slug}/admin/", "calendars"),
|
||||
("market_url", f"/{slug}/admin/", "markets"),
|
||||
("cart_url", f"/{slug}/admin/payments/", "payments"),
|
||||
("blog_url", f"/{slug}/admin/entries/", "entries"),
|
||||
("blog_url", f"/{slug}/admin/data/", "data"),
|
||||
("blog_url", f"/{slug}/admin/edit/", "edit"),
|
||||
("blog_url", f"/{slug}/admin/settings/", "settings"),
|
||||
]
|
||||
for url_key, path, label in items:
|
||||
url_fn = ctx.get(url_key)
|
||||
if not callable(url_fn):
|
||||
continue
|
||||
href = url_fn(path)
|
||||
is_sel = label == selected
|
||||
cls = selected_cls if is_sel else base_cls
|
||||
aria = ' aria-selected="true"' if is_sel else ""
|
||||
nav_parts.append(
|
||||
f'<div class="relative nav-group">'
|
||||
f'<a href="{escape(href)}"{aria}'
|
||||
f' class="{cls} {escape(select_colours)}">'
|
||||
f'{escape(label)}</a></div>'
|
||||
)
|
||||
nav_html = "".join(nav_parts)
|
||||
|
||||
if not admin_href:
|
||||
blog_fn = ctx.get("blog_url")
|
||||
admin_href = blog_fn(f"/{slug}/admin/") if callable(blog_fn) else f"/{slug}/admin/"
|
||||
|
||||
return render("menu-row",
|
||||
id="post-admin-row", level=2,
|
||||
link_href=admin_href, link_label_html=label_html,
|
||||
nav_html=nav_html, child_id="post-admin-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def oob_header_html(parent_id: str, child_id: str, row_html: str) -> str:
|
||||
"""Wrap a header row in an OOB swap div with child placeholder."""
|
||||
return render("oob-header",
|
||||
|
||||
@@ -53,11 +53,49 @@ def load_sexp_dir(directory: str) -> None:
|
||||
register_components(f.read())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-mode auto-reload of sexp templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_watched_dirs: list[str] = []
|
||||
_file_mtimes: dict[str, float] = {}
|
||||
|
||||
|
||||
def watch_sexp_dir(directory: str) -> None:
|
||||
"""Register a directory for dev-mode file watching."""
|
||||
_watched_dirs.append(directory)
|
||||
# Seed mtimes
|
||||
for fp in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sexp"))
|
||||
+ glob.glob(os.path.join(directory, "*.sexpr"))
|
||||
):
|
||||
_file_mtimes[fp] = os.path.getmtime(fp)
|
||||
|
||||
|
||||
def reload_if_changed() -> None:
|
||||
"""Re-read sexp files if any have changed on disk. Called per-request in dev."""
|
||||
changed = False
|
||||
for directory in _watched_dirs:
|
||||
for fp in sorted(
|
||||
glob.glob(os.path.join(directory, "*.sexp"))
|
||||
+ glob.glob(os.path.join(directory, "*.sexpr"))
|
||||
):
|
||||
mtime = os.path.getmtime(fp)
|
||||
if fp not in _file_mtimes or _file_mtimes[fp] != mtime:
|
||||
_file_mtimes[fp] = mtime
|
||||
changed = True
|
||||
if changed:
|
||||
_COMPONENT_ENV.clear()
|
||||
for directory in _watched_dirs:
|
||||
load_sexp_dir(directory)
|
||||
|
||||
|
||||
def load_service_components(service_dir: str) -> None:
|
||||
"""Load service-specific s-expression components from {service_dir}/sexp/."""
|
||||
sexp_dir = os.path.join(service_dir, "sexp")
|
||||
if os.path.isdir(sexp_dir):
|
||||
load_sexp_dir(sexp_dir)
|
||||
watch_sexp_dir(sexp_dir)
|
||||
|
||||
|
||||
def register_components(sexp_source: str) -> None:
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
:class "w-12 h-12 rotate-180 transition-transform group-open/root:block hidden self-start"
|
||||
(path :d "M6 9l6 6 6-6" :fill "currentColor"))))
|
||||
|
||||
(defcomp ~header-row (&key cart-mini-html blog-url site-title
|
||||
(defcomp ~header-row (&key cart-mini-html blog-url site-title app-label
|
||||
nav-tree-html auth-menu-html nav-panel-html
|
||||
settings-url is-admin oob)
|
||||
(<>
|
||||
@@ -106,7 +106,7 @@
|
||||
(div :class "w-full flex flex-row items-top"
|
||||
(when cart-mini-html (raw! cart-mini-html))
|
||||
(div :class "font-bold text-5xl flex-1"
|
||||
(a :href (or blog-url "/") :class "flex justify-center md:justify-start"
|
||||
(a :href (or blog-url "/") :class "flex justify-center md:justify-start items-baseline gap-2"
|
||||
(h1 (or site-title ""))))
|
||||
(nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0"
|
||||
(when nav-tree-html (raw! nav-tree-html))
|
||||
|
||||
0
shared/tests/__init__.py
Normal file
0
shared/tests/__init__.py
Normal file
10
shared/tests/conftest.py
Normal file
10
shared/tests/conftest.py
Normal file
@@ -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)
|
||||
119
shared/tests/test_activity_bus.py
Normal file
119
shared/tests/test_activity_bus.py
Normal file
@@ -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]
|
||||
117
shared/tests/test_calendar_helpers.py
Normal file
117
shared/tests/test_calendar_helpers.py
Normal file
@@ -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)
|
||||
152
shared/tests/test_config.py
Normal file
152
shared/tests/test_config.py
Normal file
@@ -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
|
||||
5
shared/tests/test_deliberate_failure.py
Normal file
5
shared/tests/test_deliberate_failure.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Deliberate failing test to verify test dashboard shows failures."""
|
||||
|
||||
|
||||
def test_this_should_fail():
|
||||
assert 1 == 2, "Deliberate failure to test dashboard display"
|
||||
215
shared/tests/test_dtos.py
Normal file
215
shared/tests/test_dtos.py
Normal file
@@ -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="<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
|
||||
96
shared/tests/test_errors.py
Normal file
96
shared/tests/test_errors.py
Normal file
@@ -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 "<!DOCTYPE html>" 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 <b>account</b> service is unavailable")
|
||||
assert "<b>account</b>" in html
|
||||
|
||||
def test_self_contained(self):
|
||||
"""Error page should include its own styles (no external CSS deps)."""
|
||||
html = _error_page("Error")
|
||||
assert "<style>" in html
|
||||
assert "</style>" in html
|
||||
291
shared/tests/test_filters.py
Normal file
291
shared/tests/test_filters.py
Normal file
@@ -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 "<mark" in result
|
||||
assert "World" in result
|
||||
|
||||
def test_case_insensitive(self):
|
||||
result = highlight("Hello World", "HELLO")
|
||||
assert "<mark" in result
|
||||
|
||||
def test_empty_needle(self):
|
||||
result = highlight("Hello", "")
|
||||
assert result == "Hello"
|
||||
|
||||
def test_empty_text(self):
|
||||
result = highlight("", "needle")
|
||||
assert result == ""
|
||||
|
||||
def test_none_text(self):
|
||||
result = highlight(None, "needle")
|
||||
assert result == ""
|
||||
|
||||
def test_no_match(self):
|
||||
result = highlight("Hello World", "xyz")
|
||||
assert "<mark" not in result
|
||||
assert "Hello World" in result
|
||||
|
||||
def test_escapes_html(self):
|
||||
result = highlight("<script>alert('xss')</script>", "script")
|
||||
assert "<script>" not in str(result)
|
||||
assert "<script>" in str(result) or "<" in str(result)
|
||||
|
||||
def test_custom_class(self):
|
||||
result = highlight("Hello", "Hello", cls="highlight-red")
|
||||
assert "highlight-red" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# truncate (tested as pure function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTruncate:
|
||||
"""Test the truncate logic directly."""
|
||||
|
||||
@staticmethod
|
||||
def _truncate(text, max_length=100):
|
||||
if text is None:
|
||||
return ""
|
||||
text = str(text)
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
if max_length <= 1:
|
||||
return "…"
|
||||
return text[:max_length - 1] + "…"
|
||||
|
||||
def test_short_text(self):
|
||||
assert self._truncate("hello", 10) == "hello"
|
||||
|
||||
def test_exact_length(self):
|
||||
assert self._truncate("hello", 5) == "hello"
|
||||
|
||||
def test_truncates_long(self):
|
||||
result = self._truncate("hello world", 6)
|
||||
assert result == "hello…"
|
||||
assert len(result) == 6
|
||||
|
||||
def test_none_input(self):
|
||||
assert self._truncate(None) == ""
|
||||
|
||||
def test_max_length_one(self):
|
||||
assert self._truncate("hello", 1) == "…"
|
||||
|
||||
def test_max_length_zero(self):
|
||||
assert self._truncate("hello", 0) == "…"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# currency (tested as pure function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCurrency:
|
||||
@staticmethod
|
||||
def _currency(value, code="GBP"):
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float):
|
||||
value = Decimal(str(value))
|
||||
symbol = "£" if code == "GBP" else code
|
||||
return f"{symbol}{value:.2f}"
|
||||
|
||||
def test_gbp(self):
|
||||
assert self._currency(Decimal("19.99")) == "£19.99"
|
||||
|
||||
def test_none(self):
|
||||
assert self._currency(None) == ""
|
||||
|
||||
def test_float_conversion(self):
|
||||
assert self._currency(19.99) == "£19.99"
|
||||
|
||||
def test_non_gbp(self):
|
||||
assert self._currency(Decimal("10.00"), "EUR") == "EUR10.00"
|
||||
|
||||
def test_zero(self):
|
||||
assert self._currency(Decimal("0")) == "£0.00"
|
||||
|
||||
def test_integer_decimal(self):
|
||||
assert self._currency(Decimal("5")) == "£5.00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# combine / _deep_merge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeepMerge:
|
||||
def test_simple_merge(self):
|
||||
assert _deep_merge({"a": 1}, {"b": 2}) == {"a": 1, "b": 2}
|
||||
|
||||
def test_overwrite(self):
|
||||
assert _deep_merge({"a": 1}, {"a": 2}) == {"a": 2}
|
||||
|
||||
def test_nested_merge(self):
|
||||
a = {"x": {"a": 1, "b": 2}}
|
||||
b = {"x": {"b": 3, "c": 4}}
|
||||
result = _deep_merge(a, b)
|
||||
assert result == {"x": {"a": 1, "b": 3, "c": 4}}
|
||||
|
||||
def test_deeply_nested(self):
|
||||
a = {"x": {"y": {"z": 1}}}
|
||||
b = {"x": {"y": {"w": 2}}}
|
||||
result = _deep_merge(a, b)
|
||||
assert result == {"x": {"y": {"z": 1, "w": 2}}}
|
||||
|
||||
def test_does_not_mutate_original(self):
|
||||
a = {"a": 1}
|
||||
b = {"b": 2}
|
||||
_deep_merge(a, b)
|
||||
assert a == {"a": 1}
|
||||
|
||||
|
||||
class TestCombineFilter:
|
||||
"""Test the combine filter logic inline (it's defined inside register())."""
|
||||
from typing import Any, Mapping
|
||||
|
||||
@staticmethod
|
||||
def _combine(a, b, deep=False, drop_none=False):
|
||||
from collections.abc import Mapping
|
||||
if not isinstance(a, Mapping) or not isinstance(b, Mapping):
|
||||
return a
|
||||
b2 = {k: v for k, v in b.items() if not (drop_none and v is None)}
|
||||
return _deep_merge(a, b2) if deep else {**a, **b2}
|
||||
|
||||
def test_non_dict_returns_a(self):
|
||||
assert self._combine("hello", {"a": 1}) == "hello"
|
||||
assert self._combine(42, {"a": 1}) == 42
|
||||
|
||||
def test_shallow_merge(self):
|
||||
result = self._combine({"a": 1}, {"b": 2})
|
||||
assert result == {"a": 1, "b": 2}
|
||||
|
||||
def test_deep_merge(self):
|
||||
result = self._combine({"x": {"a": 1}}, {"x": {"b": 2}}, deep=True)
|
||||
assert result == {"x": {"a": 1, "b": 2}}
|
||||
|
||||
def test_drop_none(self):
|
||||
result = self._combine({"a": 1, "b": 2}, {"b": None, "c": 3}, drop_none=True)
|
||||
assert result == {"a": 1, "b": 2, "c": 3}
|
||||
|
||||
def test_keep_none_when_not_dropping(self):
|
||||
result = self._combine({"a": 1}, {"a": None})
|
||||
assert result == {"a": None}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# qs_base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIterify:
|
||||
def test_none(self):
|
||||
assert _iterify(None) == []
|
||||
|
||||
def test_scalar(self):
|
||||
assert _iterify("hello") == ["hello"]
|
||||
|
||||
def test_list(self):
|
||||
assert _iterify([1, 2]) == [1, 2]
|
||||
|
||||
def test_tuple(self):
|
||||
assert _iterify((1, 2)) == (1, 2)
|
||||
|
||||
def test_set(self):
|
||||
assert _iterify({1, 2}) == {1, 2}
|
||||
|
||||
|
||||
class TestNorm:
|
||||
def test_strips_and_lowercases(self):
|
||||
assert _norm(" Hello ") == "hello"
|
||||
|
||||
def test_already_lower(self):
|
||||
assert _norm("hello") == "hello"
|
||||
|
||||
|
||||
class TestMakeFilterSet:
|
||||
def test_add_to_empty(self):
|
||||
result = make_filter_set([], "new", None, False)
|
||||
assert result == ["new"]
|
||||
|
||||
def test_add_preserves_existing(self):
|
||||
result = make_filter_set(["a", "b"], "c", None, False)
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_remove(self):
|
||||
result = make_filter_set(["a", "b", "c"], None, "b", False)
|
||||
assert result == ["a", "c"]
|
||||
|
||||
def test_clear_filters(self):
|
||||
result = make_filter_set(["a", "b"], None, None, True)
|
||||
assert result == []
|
||||
|
||||
def test_clear_then_add(self):
|
||||
result = make_filter_set(["a", "b"], "c", None, True)
|
||||
assert result == ["c"]
|
||||
|
||||
def test_case_insensitive_dedup(self):
|
||||
result = make_filter_set(["Hello"], "hello", None, False)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_sorted_output(self):
|
||||
result = make_filter_set([], ["c", "a", "b"], None, False)
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_single_select_replaces(self):
|
||||
result = make_filter_set(["old1", "old2"], "new", None, False, single_select=True)
|
||||
assert result == ["new"]
|
||||
|
||||
def test_single_select_no_add_keeps_base(self):
|
||||
result = make_filter_set(["a", "b"], None, None, False, single_select=True)
|
||||
assert result == ["a", "b"]
|
||||
|
||||
def test_remove_case_insensitive(self):
|
||||
result = make_filter_set(["Hello", "World"], None, "hello", False)
|
||||
assert result == ["World"]
|
||||
|
||||
def test_add_none_values_filtered(self):
|
||||
result = make_filter_set([], [None, "a", None], None, False)
|
||||
assert result == ["a"]
|
||||
|
||||
|
||||
class TestBuildQs:
|
||||
def test_basic(self):
|
||||
result = build_qs([("key", "value")])
|
||||
assert result == "?key=value"
|
||||
|
||||
def test_multiple_params(self):
|
||||
result = build_qs([("a", "1"), ("b", "2")])
|
||||
assert "a=1" in result
|
||||
assert "b=2" in result
|
||||
assert result.startswith("?")
|
||||
|
||||
def test_no_leading_q(self):
|
||||
result = build_qs([("key", "value")], leading_q=False)
|
||||
assert result == "key=value"
|
||||
|
||||
def test_empty_params(self):
|
||||
result = build_qs([])
|
||||
assert result == ""
|
||||
|
||||
def test_empty_params_no_leading(self):
|
||||
result = build_qs([], leading_q=False)
|
||||
assert result == ""
|
||||
140
shared/tests/test_http_signatures.py
Normal file
140
shared/tests/test_http_signatures.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Tests for RSA key generation and HTTP Signature signing/verification."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from shared.utils.http_signatures import (
|
||||
generate_rsa_keypair,
|
||||
sign_request,
|
||||
verify_request_signature,
|
||||
create_ld_signature,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKeyGeneration:
|
||||
def test_generates_pem_strings(self):
|
||||
private_pem, public_pem = generate_rsa_keypair()
|
||||
assert isinstance(private_pem, str)
|
||||
assert isinstance(public_pem, str)
|
||||
|
||||
def test_private_key_format(self):
|
||||
private_pem, _ = generate_rsa_keypair()
|
||||
assert "BEGIN PRIVATE KEY" in private_pem
|
||||
assert "END PRIVATE KEY" in private_pem
|
||||
|
||||
def test_public_key_format(self):
|
||||
_, public_pem = generate_rsa_keypair()
|
||||
assert "BEGIN PUBLIC KEY" in public_pem
|
||||
assert "END PUBLIC KEY" in public_pem
|
||||
|
||||
def test_keys_are_unique(self):
|
||||
priv1, pub1 = generate_rsa_keypair()
|
||||
priv2, pub2 = generate_rsa_keypair()
|
||||
assert priv1 != priv2
|
||||
assert pub1 != pub2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sign + verify round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignVerify:
|
||||
def test_round_trip_no_body(self):
|
||||
private_pem, public_pem = generate_rsa_keypair()
|
||||
headers = sign_request(
|
||||
private_pem, key_id="https://example.com/users/alice#main-key",
|
||||
method="GET", path="/users/bob/inbox", host="example.com",
|
||||
date="Sat, 15 Jun 2025 12:00:00 GMT",
|
||||
)
|
||||
assert "Signature" in headers
|
||||
assert "Date" in headers
|
||||
assert "Host" in headers
|
||||
assert "Digest" not in headers
|
||||
|
||||
ok = verify_request_signature(
|
||||
public_pem, headers["Signature"], method="GET",
|
||||
path="/users/bob/inbox", headers=headers,
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_round_trip_with_body(self):
|
||||
private_pem, public_pem = generate_rsa_keypair()
|
||||
body = b'{"type": "Follow"}'
|
||||
headers = sign_request(
|
||||
private_pem, key_id="https://example.com/users/alice#main-key",
|
||||
method="POST", path="/users/bob/inbox", host="example.com",
|
||||
body=body, date="Sat, 15 Jun 2025 12:00:00 GMT",
|
||||
)
|
||||
assert "Digest" in headers
|
||||
assert headers["Digest"].startswith("SHA-256=")
|
||||
|
||||
ok = verify_request_signature(
|
||||
public_pem, headers["Signature"], method="POST",
|
||||
path="/users/bob/inbox", headers=headers,
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_wrong_key_fails(self):
|
||||
priv1, _ = generate_rsa_keypair()
|
||||
_, pub2 = generate_rsa_keypair()
|
||||
headers = sign_request(
|
||||
priv1, key_id="key1", method="GET", path="/inbox", host="a.com",
|
||||
date="Sat, 15 Jun 2025 12:00:00 GMT",
|
||||
)
|
||||
ok = verify_request_signature(pub2, headers["Signature"], "GET", "/inbox", headers)
|
||||
assert ok is False
|
||||
|
||||
def test_tampered_path_fails(self):
|
||||
private_pem, public_pem = generate_rsa_keypair()
|
||||
headers = sign_request(
|
||||
private_pem, key_id="key1", method="GET", path="/inbox", host="a.com",
|
||||
date="Sat, 15 Jun 2025 12:00:00 GMT",
|
||||
)
|
||||
ok = verify_request_signature(public_pem, headers["Signature"], "GET", "/tampered", headers)
|
||||
assert ok is False
|
||||
|
||||
def test_tampered_method_fails(self):
|
||||
private_pem, public_pem = generate_rsa_keypair()
|
||||
headers = sign_request(
|
||||
private_pem, key_id="key1", method="GET", path="/inbox", host="a.com",
|
||||
date="Sat, 15 Jun 2025 12:00:00 GMT",
|
||||
)
|
||||
ok = verify_request_signature(public_pem, headers["Signature"], "POST", "/inbox", headers)
|
||||
assert ok is False
|
||||
|
||||
def test_signature_header_contains_key_id(self):
|
||||
private_pem, _ = generate_rsa_keypair()
|
||||
headers = sign_request(
|
||||
private_pem, key_id="https://my.server/actor#main-key",
|
||||
method="POST", path="/inbox", host="remote.server",
|
||||
date="Sat, 15 Jun 2025 12:00:00 GMT",
|
||||
)
|
||||
assert 'keyId="https://my.server/actor#main-key"' in headers["Signature"]
|
||||
assert 'algorithm="rsa-sha256"' in headers["Signature"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Linked Data signature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLDSignature:
|
||||
def test_creates_ld_signature(self):
|
||||
private_pem, _ = generate_rsa_keypair()
|
||||
activity = {"type": "Create", "actor": "https://example.com/users/alice"}
|
||||
sig = create_ld_signature(private_pem, "https://example.com/users/alice#main-key", activity)
|
||||
assert sig["type"] == "RsaSignature2017"
|
||||
assert sig["creator"] == "https://example.com/users/alice#main-key"
|
||||
assert "signatureValue" in sig
|
||||
assert "created" in sig
|
||||
|
||||
def test_deterministic_canonical(self):
|
||||
"""Same activity always produces same canonical form (signature differs due to timestamp)."""
|
||||
private_pem, _ = generate_rsa_keypair()
|
||||
activity = {"b": 2, "a": 1}
|
||||
# The canonical form should sort keys
|
||||
canonical = json.dumps(activity, sort_keys=True, separators=(",", ":"))
|
||||
assert canonical == '{"a":1,"b":2}'
|
||||
114
shared/tests/test_internal_auth.py
Normal file
114
shared/tests/test_internal_auth.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Tests for HMAC-based internal service-to-service authentication."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from shared.infrastructure.internal_auth import sign_internal_headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sign_internal_headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignInternalHeaders:
|
||||
def test_returns_required_headers(self):
|
||||
headers = sign_internal_headers("cart")
|
||||
assert "X-Internal-Timestamp" in headers
|
||||
assert "X-Internal-App" in headers
|
||||
assert "X-Internal-Signature" in headers
|
||||
|
||||
def test_app_name_in_header(self):
|
||||
headers = sign_internal_headers("blog")
|
||||
assert headers["X-Internal-App"] == "blog"
|
||||
|
||||
def test_timestamp_is_recent(self):
|
||||
headers = sign_internal_headers("events")
|
||||
ts = int(headers["X-Internal-Timestamp"])
|
||||
now = int(time.time())
|
||||
assert abs(now - ts) < 5
|
||||
|
||||
def test_signature_is_hex(self):
|
||||
headers = sign_internal_headers("cart")
|
||||
sig = headers["X-Internal-Signature"]
|
||||
# SHA-256 hex is 64 chars
|
||||
assert len(sig) == 64
|
||||
int(sig, 16) # should not raise
|
||||
|
||||
def test_different_apps_different_signatures(self):
|
||||
h1 = sign_internal_headers("cart")
|
||||
h2 = sign_internal_headers("blog")
|
||||
assert h1["X-Internal-Signature"] != h2["X-Internal-Signature"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip: sign then validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignAndValidate:
|
||||
"""Test the HMAC signing logic directly without needing a Quart request context."""
|
||||
|
||||
def _validate_headers(self, headers: dict[str, str], secret: bytes, max_age: int = 300) -> bool:
|
||||
"""Replicate validate_internal_request logic without Quart request context."""
|
||||
ts = headers.get("X-Internal-Timestamp", "")
|
||||
app_name = headers.get("X-Internal-App", "")
|
||||
sig = headers.get("X-Internal-Signature", "")
|
||||
|
||||
if not ts or not app_name or not sig:
|
||||
return False
|
||||
|
||||
try:
|
||||
req_time = int(ts)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
now = int(time.time())
|
||||
if abs(now - req_time) > max_age:
|
||||
return False
|
||||
|
||||
payload = f"{ts}:{app_name}".encode()
|
||||
expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
def test_valid_signature(self):
|
||||
from shared.infrastructure.internal_auth import _get_secret
|
||||
secret = _get_secret()
|
||||
headers = sign_internal_headers("relations")
|
||||
assert self._validate_headers(headers, secret) is True
|
||||
|
||||
def test_tampered_signature_fails(self):
|
||||
from shared.infrastructure.internal_auth import _get_secret
|
||||
secret = _get_secret()
|
||||
headers = sign_internal_headers("cart")
|
||||
headers["X-Internal-Signature"] = "0" * 64
|
||||
assert self._validate_headers(headers, secret) is False
|
||||
|
||||
def test_wrong_secret_fails(self):
|
||||
headers = sign_internal_headers("cart")
|
||||
assert self._validate_headers(headers, b"wrong-secret") is False
|
||||
|
||||
def test_expired_timestamp_fails(self):
|
||||
from shared.infrastructure.internal_auth import _get_secret
|
||||
secret = _get_secret()
|
||||
headers = sign_internal_headers("cart")
|
||||
# Set timestamp to 10 minutes ago
|
||||
old_ts = str(int(time.time()) - 600)
|
||||
headers["X-Internal-Timestamp"] = old_ts
|
||||
# Re-sign with old timestamp (so the signature matches the old ts)
|
||||
payload = f"{old_ts}:cart".encode()
|
||||
headers["X-Internal-Signature"] = hmac.new(secret, payload, hashlib.sha256).hexdigest()
|
||||
assert self._validate_headers(headers, secret) is False
|
||||
|
||||
def test_missing_headers_fail(self):
|
||||
from shared.infrastructure.internal_auth import _get_secret
|
||||
secret = _get_secret()
|
||||
assert self._validate_headers({}, secret) is False
|
||||
assert self._validate_headers({"X-Internal-Timestamp": "123"}, secret) is False
|
||||
|
||||
def test_invalid_timestamp_fails(self):
|
||||
from shared.infrastructure.internal_auth import _get_secret
|
||||
secret = _get_secret()
|
||||
headers = {"X-Internal-Timestamp": "not-a-number", "X-Internal-App": "cart", "X-Internal-Signature": "abc"}
|
||||
assert self._validate_headers(headers, secret) is False
|
||||
166
shared/tests/test_jinja_bridge_render.py
Normal file
166
shared/tests/test_jinja_bridge_render.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Tests for the render() function and component loading in jinja_bridge.
|
||||
|
||||
These test functionality added in recent commits (render() API,
|
||||
load_sexp_dir, snake→kebab conversion) that isn't covered by the existing
|
||||
shared/sexp/tests/test_jinja_bridge.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.sexp.jinja_bridge import (
|
||||
render,
|
||||
register_components,
|
||||
load_sexp_dir,
|
||||
_COMPONENT_ENV,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env():
|
||||
"""Clear component env before each test."""
|
||||
_COMPONENT_ENV.clear()
|
||||
yield
|
||||
_COMPONENT_ENV.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render() — call component by name with Python kwargs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRender:
|
||||
def test_basic_render(self):
|
||||
register_components('(defcomp ~badge (&key label) (span :class "badge" label))')
|
||||
html = render("badge", label="New")
|
||||
assert html == '<span class="badge">New</span>'
|
||||
|
||||
def test_tilde_prefix_optional(self):
|
||||
register_components('(defcomp ~pill (&key text) (em text))')
|
||||
# Both forms should work
|
||||
assert render("pill", text="Hi") == render("~pill", text="Hi")
|
||||
|
||||
def test_snake_to_kebab_conversion(self):
|
||||
"""Python snake_case kwargs should map to sexp kebab-case params."""
|
||||
register_components('''
|
||||
(defcomp ~card (&key nav-html link-href)
|
||||
(div :class "card" (a :href link-href nav-html)))
|
||||
''')
|
||||
html = render("card", nav_html="Nav", link_href="/about")
|
||||
assert 'href="/about"' in html
|
||||
assert "Nav" in html
|
||||
|
||||
def test_multiple_kwargs(self):
|
||||
register_components('''
|
||||
(defcomp ~item (&key title price image-url)
|
||||
(div (h3 title) (span price) (img :src image-url)))
|
||||
''')
|
||||
html = render("item", title="Widget", price="£10", image_url="/img/w.jpg")
|
||||
assert "Widget" in html
|
||||
assert "£10" in html
|
||||
assert 'src="/img/w.jpg"' in html
|
||||
|
||||
def test_unknown_component_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown component"):
|
||||
render("nonexistent", label="x")
|
||||
|
||||
def test_empty_kwargs(self):
|
||||
register_components('(defcomp ~empty () (hr))')
|
||||
html = render("empty")
|
||||
assert html == "<hr>"
|
||||
|
||||
def test_html_escaping_in_values(self):
|
||||
register_components('(defcomp ~safe (&key text) (p text))')
|
||||
html = render("safe", text='<script>alert("xss")</script>')
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
def test_boolean_false_value(self):
|
||||
register_components('''
|
||||
(defcomp ~toggle (&key active)
|
||||
(when active (span "ON")))
|
||||
''')
|
||||
html = render("toggle", active=False)
|
||||
assert "ON" not in html
|
||||
|
||||
def test_boolean_true_value(self):
|
||||
register_components('''
|
||||
(defcomp ~toggle (&key active)
|
||||
(when active (span "ON")))
|
||||
''')
|
||||
html = render("toggle", active=True)
|
||||
assert "ON" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_sexp_dir
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoadSexpDir:
|
||||
def test_loads_sexp_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Write a .sexp file
|
||||
with open(os.path.join(tmpdir, "components.sexp"), "w") as f:
|
||||
f.write('(defcomp ~test-comp (&key msg) (div msg))')
|
||||
|
||||
load_sexp_dir(tmpdir)
|
||||
html = render("test-comp", msg="loaded!")
|
||||
assert html == "<div>loaded!</div>"
|
||||
|
||||
def test_loads_sexpr_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, "nav.sexpr"), "w") as f:
|
||||
f.write('(defcomp ~nav-item (&key href label) (a :href href label))')
|
||||
|
||||
load_sexp_dir(tmpdir)
|
||||
html = render("nav-item", href="/about", label="About")
|
||||
assert 'href="/about"' in html
|
||||
|
||||
def test_loads_multiple_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, "a.sexp"), "w") as f:
|
||||
f.write('(defcomp ~comp-a (&key x) (b x))')
|
||||
with open(os.path.join(tmpdir, "b.sexp"), "w") as f:
|
||||
f.write('(defcomp ~comp-b (&key y) (i y))')
|
||||
|
||||
load_sexp_dir(tmpdir)
|
||||
assert render("comp-a", x="A") == "<b>A</b>"
|
||||
assert render("comp-b", y="B") == "<i>B</i>"
|
||||
|
||||
def test_empty_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
load_sexp_dir(tmpdir) # should not raise
|
||||
|
||||
def test_ignores_non_sexp_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, "readme.txt"), "w") as f:
|
||||
f.write("not a sexp file")
|
||||
with open(os.path.join(tmpdir, "comp.sexp"), "w") as f:
|
||||
f.write('(defcomp ~real (&key v) (span v))')
|
||||
|
||||
load_sexp_dir(tmpdir)
|
||||
assert "~real" in _COMPONENT_ENV
|
||||
# txt file should not have been loaded
|
||||
assert len([k for k in _COMPONENT_ENV if k.startswith("~")]) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# register_components — multiple definitions in one source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegisterComponents:
|
||||
def test_multiple_in_one_source(self):
|
||||
register_components('''
|
||||
(defcomp ~a (&key x) (b x))
|
||||
(defcomp ~b (&key y) (i y))
|
||||
''')
|
||||
assert "~a" in _COMPONENT_ENV
|
||||
assert "~b" in _COMPONENT_ENV
|
||||
|
||||
def test_overwrite_existing(self):
|
||||
register_components('(defcomp ~ow (&key x) (b x))')
|
||||
assert render("ow", x="v1") == "<b>v1</b>"
|
||||
register_components('(defcomp ~ow (&key x) (i x))')
|
||||
assert render("ow", x="v2") == "<i>v2</i>"
|
||||
95
shared/tests/test_parse_utils.py
Normal file
95
shared/tests/test_parse_utils.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for parse utility functions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time, datetime, timezone
|
||||
|
||||
from shared.browser.app.utils.parse import parse_time, parse_cost, parse_dt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseTime:
|
||||
def test_valid_time(self):
|
||||
result = parse_time("14:30")
|
||||
assert result == time(14, 30)
|
||||
|
||||
def test_midnight(self):
|
||||
result = parse_time("00:00")
|
||||
assert result == time(0, 0)
|
||||
|
||||
def test_end_of_day(self):
|
||||
result = parse_time("23:59")
|
||||
assert result == time(23, 59)
|
||||
|
||||
def test_none_input(self):
|
||||
assert parse_time(None) is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_time("") is None
|
||||
|
||||
def test_invalid_format(self):
|
||||
assert parse_time("not-a-time") is None
|
||||
|
||||
def test_invalid_hours(self):
|
||||
assert parse_time("25:00") is None
|
||||
|
||||
def test_single_digit(self):
|
||||
result = parse_time("9:05")
|
||||
assert result == time(9, 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_cost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseCost:
|
||||
def test_valid_float(self):
|
||||
assert parse_cost("19.99") == 19.99
|
||||
|
||||
def test_integer_string(self):
|
||||
assert parse_cost("10") == 10.0
|
||||
|
||||
def test_zero(self):
|
||||
assert parse_cost("0") == 0.0
|
||||
|
||||
def test_none_input(self):
|
||||
assert parse_cost(None) is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_cost("") is None
|
||||
|
||||
def test_invalid_string(self):
|
||||
assert parse_cost("not-a-number") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_dt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseDt:
|
||||
def test_iso_format(self):
|
||||
result = parse_dt("2025-06-15T14:30:00")
|
||||
assert isinstance(result, datetime)
|
||||
assert result.year == 2025
|
||||
assert result.month == 6
|
||||
assert result.day == 15
|
||||
|
||||
def test_naive_gets_utc(self):
|
||||
result = parse_dt("2025-06-15T14:30:00")
|
||||
assert result.tzinfo == timezone.utc
|
||||
|
||||
def test_aware_preserved(self):
|
||||
result = parse_dt("2025-06-15T14:30:00+01:00")
|
||||
assert result.tzinfo is not None
|
||||
|
||||
def test_none_input(self):
|
||||
assert parse_dt(None) is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_dt("") is None
|
||||
|
||||
def test_date_only(self):
|
||||
result = parse_dt("2025-06-15")
|
||||
assert result.year == 2025
|
||||
74
shared/tests/test_sexp_helpers.py
Normal file
74
shared/tests/test_sexp_helpers.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for shared sexp helper functions (call_url, get_asset_url, etc.)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sexp.helpers import call_url, get_asset_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# call_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCallUrl:
|
||||
def test_callable_url_fn(self):
|
||||
ctx = {"blog_url": lambda path: f"https://blog.example.com{path}"}
|
||||
assert call_url(ctx, "blog_url", "/posts/") == "https://blog.example.com/posts/"
|
||||
|
||||
def test_callable_default_path(self):
|
||||
ctx = {"blog_url": lambda path: f"https://blog.example.com{path}"}
|
||||
assert call_url(ctx, "blog_url") == "https://blog.example.com/"
|
||||
|
||||
def test_string_url(self):
|
||||
ctx = {"blog_url": "https://blog.example.com"}
|
||||
assert call_url(ctx, "blog_url", "/posts/") == "https://blog.example.com/posts/"
|
||||
|
||||
def test_string_url_default_path(self):
|
||||
ctx = {"blog_url": "https://blog.example.com"}
|
||||
assert call_url(ctx, "blog_url") == "https://blog.example.com/"
|
||||
|
||||
def test_missing_key(self):
|
||||
ctx = {}
|
||||
assert call_url(ctx, "blog_url", "/x") == "/x"
|
||||
|
||||
def test_none_value(self):
|
||||
ctx = {"blog_url": None}
|
||||
assert call_url(ctx, "blog_url", "/x") == "/x"
|
||||
|
||||
def test_callable_with_empty_path(self):
|
||||
ctx = {"cart_url": lambda path: f"https://cart.example.com{path}"}
|
||||
assert call_url(ctx, "cart_url", "") == "https://cart.example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_asset_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetAssetUrl:
|
||||
def test_callable_asset_url(self):
|
||||
ctx = {"asset_url": lambda path: f"https://cdn.example.com/static{path}"}
|
||||
result = get_asset_url(ctx)
|
||||
# Should strip the trailing path component
|
||||
assert "cdn.example.com" in result
|
||||
|
||||
def test_string_asset_url(self):
|
||||
ctx = {"asset_url": "https://cdn.example.com/static"}
|
||||
assert get_asset_url(ctx) == "https://cdn.example.com/static"
|
||||
|
||||
def test_missing_asset_url(self):
|
||||
ctx = {}
|
||||
assert get_asset_url(ctx) == ""
|
||||
|
||||
def test_none_asset_url(self):
|
||||
ctx = {"asset_url": None}
|
||||
assert get_asset_url(ctx) == ""
|
||||
|
||||
def test_callable_returns_path_only(self):
|
||||
# au("") → "/static", rsplit("/",1)[0] → "" (splits on leading /)
|
||||
ctx = {"asset_url": lambda path: f"/static{path}"}
|
||||
result = get_asset_url(ctx)
|
||||
assert result == ""
|
||||
|
||||
def test_callable_with_nested_path(self):
|
||||
# au("") → "/assets/static", rsplit("/",1)[0] → "/assets"
|
||||
ctx = {"asset_url": lambda path: f"/assets/static{path}"}
|
||||
result = get_asset_url(ctx)
|
||||
assert result == "/assets"
|
||||
108
shared/tests/test_url_utils.py
Normal file
108
shared/tests/test_url_utils.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tests for URL join utilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.utils import _join_url_parts, join_url, normalize_text, soup_of
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _join_url_parts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestJoinUrlParts:
|
||||
def test_empty_list(self):
|
||||
assert _join_url_parts([]) == ""
|
||||
|
||||
def test_single_part(self):
|
||||
assert _join_url_parts(["hello"]) == "hello"
|
||||
|
||||
def test_two_parts(self):
|
||||
assert _join_url_parts(["https://example.com", "path"]) == "https://example.com/path"
|
||||
|
||||
def test_preserves_scheme(self):
|
||||
assert _join_url_parts(["https://example.com/", "/api/", "v1"]) == "https://example.com/api/v1"
|
||||
|
||||
def test_trailing_slash_preserved(self):
|
||||
result = _join_url_parts(["https://example.com", "path/"])
|
||||
assert result.endswith("/")
|
||||
|
||||
def test_no_trailing_slash_when_last_has_none(self):
|
||||
result = _join_url_parts(["https://example.com", "path"])
|
||||
assert not result.endswith("/")
|
||||
|
||||
def test_strips_internal_slashes(self):
|
||||
result = _join_url_parts(["https://example.com/", "/api/", "/v1"])
|
||||
assert result == "https://example.com/api/v1"
|
||||
|
||||
def test_absolute_url_mid_list_replaces(self):
|
||||
result = _join_url_parts(["https://old.com/foo", "https://new.com/bar"])
|
||||
assert result == "https://new.com/bar"
|
||||
|
||||
def test_query_string_attached(self):
|
||||
result = _join_url_parts(["https://example.com/path", "?key=val"])
|
||||
assert result == "https://example.com/path?key=val"
|
||||
|
||||
def test_fragment_attached(self):
|
||||
result = _join_url_parts(["https://example.com/path", "#section"])
|
||||
assert result == "https://example.com/path#section"
|
||||
|
||||
def test_filters_none_and_empty(self):
|
||||
result = _join_url_parts(["https://example.com", None, "", "path"])
|
||||
assert result == "https://example.com/path"
|
||||
|
||||
def test_no_scheme(self):
|
||||
result = _join_url_parts(["/foo", "bar", "baz/"])
|
||||
assert result == "foo/bar/baz/"
|
||||
|
||||
def test_multiple_segments(self):
|
||||
result = _join_url_parts(["https://example.com", "a", "b", "c/"])
|
||||
assert result == "https://example.com/a/b/c/"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# join_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestJoinUrl:
|
||||
def test_string_input(self):
|
||||
assert join_url("https://example.com") == "https://example.com"
|
||||
|
||||
def test_list_input(self):
|
||||
assert join_url(["https://example.com", "path"]) == "https://example.com/path"
|
||||
|
||||
def test_tuple_input(self):
|
||||
assert join_url(("https://example.com", "path/")) == "https://example.com/path/"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizeText:
|
||||
def test_collapses_whitespace(self):
|
||||
assert normalize_text(" hello world ") == "hello world"
|
||||
|
||||
def test_tabs_and_newlines(self):
|
||||
assert normalize_text("hello\t\nworld") == "hello world"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert normalize_text("") == ""
|
||||
|
||||
def test_none_input(self):
|
||||
assert normalize_text(None) == ""
|
||||
|
||||
def test_single_word(self):
|
||||
assert normalize_text(" word ") == "word"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# soup_of
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSoupOf:
|
||||
def test_parses_html(self):
|
||||
s = soup_of("<p>Hello <b>world</b></p>")
|
||||
assert s.find("b").text == "world"
|
||||
|
||||
def test_empty_html(self):
|
||||
s = soup_of("")
|
||||
assert s.text == ""
|
||||
60
test/Dockerfile
Normal file
60
test/Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
APP_PORT=8000 \
|
||||
APP_MODULE=app:app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY shared/requirements.txt ./requirements.txt
|
||||
RUN pip install -r requirements.txt && \
|
||||
pip install pytest pytest-json-report
|
||||
|
||||
# Shared code (including tests)
|
||||
COPY shared/ ./shared/
|
||||
|
||||
# App code
|
||||
COPY test/ ./test-app-tmp/
|
||||
# Move service files into /app (flatten), but keep Dockerfile.* in place
|
||||
RUN cp -r test-app-tmp/app.py test-app-tmp/path_setup.py \
|
||||
test-app-tmp/bp test-app-tmp/sexp test-app-tmp/services \
|
||||
test-app-tmp/runner.py test-app-tmp/__init__.py ./ 2>/dev/null || true && \
|
||||
rm -rf test-app-tmp
|
||||
|
||||
# Sibling models for cross-domain SQLAlchemy imports
|
||||
COPY blog/__init__.py ./blog/__init__.py
|
||||
COPY blog/models/ ./blog/models/
|
||||
COPY market/__init__.py ./market/__init__.py
|
||||
COPY market/models/ ./market/models/
|
||||
COPY cart/__init__.py ./cart/__init__.py
|
||||
COPY cart/models/ ./cart/models/
|
||||
COPY events/__init__.py ./events/__init__.py
|
||||
COPY events/models/ ./events/models/
|
||||
COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
COPY test/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE ${APP_PORT}
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
0
test/__init__.py
Normal file
0
test/__init__.py
Normal file
53
test/app.py
Normal file
53
test/app.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401
|
||||
import sexp.sexp_components as sexp_components # noqa: F401
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
from shared.sexp.jinja_bridge import render
|
||||
|
||||
from bp import register_dashboard
|
||||
from services import register_domain_services
|
||||
|
||||
|
||||
async def test_context() -> dict:
|
||||
"""Test app context processor — minimal, no cross-service fragments."""
|
||||
from shared.infrastructure.context import base_context
|
||||
|
||||
ctx = await base_context()
|
||||
ctx["menu_items"] = []
|
||||
# Render cart-mini with cart_count=0 to show the logo image
|
||||
blog_url = ctx.get("blog_url", "")
|
||||
if callable(blog_url):
|
||||
blog_url_str = blog_url("")
|
||||
else:
|
||||
blog_url_str = str(blog_url or "")
|
||||
ctx["cart_mini_html"] = render(
|
||||
"cart-mini", cart_count=0, blog_url=blog_url_str, cart_url="",
|
||||
)
|
||||
ctx["auth_menu_html"] = ""
|
||||
ctx["nav_tree_html"] = ""
|
||||
return ctx
|
||||
|
||||
|
||||
def create_app() -> "Quart":
|
||||
app = create_base_app(
|
||||
"test",
|
||||
context_fn=test_context,
|
||||
domain_services_fn=register_domain_services,
|
||||
)
|
||||
|
||||
import sexp.sexp_components # noqa: F401
|
||||
|
||||
app.register_blueprint(register_dashboard(url_prefix="/"))
|
||||
|
||||
# Run tests on startup
|
||||
@app.before_serving
|
||||
async def _run_tests_on_startup():
|
||||
import runner
|
||||
import asyncio
|
||||
asyncio.create_task(runner.run_tests())
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
1
test/bp/__init__.py
Normal file
1
test/bp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .dashboard.routes import register as register_dashboard
|
||||
0
test/bp/dashboard/__init__.py
Normal file
0
test/bp/dashboard/__init__.py
Normal file
76
test/bp/dashboard/routes.py
Normal file
76
test/bp/dashboard/routes.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Test dashboard routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from quart import Blueprint, Response, make_response, request
|
||||
|
||||
|
||||
def register(url_prefix: str = "/") -> Blueprint:
|
||||
bp = Blueprint("dashboard", __name__, url_prefix=url_prefix)
|
||||
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
"""Full page dashboard with last results."""
|
||||
from shared.sexp.page import get_template_context
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from sexp.sexp_components import render_dashboard_page
|
||||
import runner
|
||||
|
||||
ctx = await get_template_context()
|
||||
result = runner.get_results()
|
||||
running = runner.is_running()
|
||||
csrf = generate_csrf_token()
|
||||
active_filter = request.args.get("filter")
|
||||
active_service = request.args.get("service")
|
||||
|
||||
html = await render_dashboard_page(
|
||||
ctx, result, running, csrf,
|
||||
active_filter=active_filter,
|
||||
active_service=active_service,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.post("/run")
|
||||
async def run():
|
||||
"""Trigger a test run, redirect to /."""
|
||||
import runner
|
||||
|
||||
if not runner.is_running():
|
||||
asyncio.create_task(runner.run_tests())
|
||||
|
||||
# HX-Redirect for HTMX, regular redirect for non-HTMX
|
||||
if request.headers.get("HX-Request"):
|
||||
resp = Response("", status=200)
|
||||
resp.headers["HX-Redirect"] = "/"
|
||||
return resp
|
||||
|
||||
from quart import redirect as qredirect
|
||||
return qredirect("/")
|
||||
|
||||
@bp.get("/results")
|
||||
async def results():
|
||||
"""HTMX partial — poll target for results table."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from sexp.sexp_components import render_results_partial
|
||||
import runner
|
||||
|
||||
result = runner.get_results()
|
||||
running = runner.is_running()
|
||||
csrf = generate_csrf_token()
|
||||
active_filter = request.args.get("filter")
|
||||
active_service = request.args.get("service")
|
||||
|
||||
html = await render_results_partial(
|
||||
result, running, csrf,
|
||||
active_filter=active_filter,
|
||||
active_service=active_service,
|
||||
)
|
||||
|
||||
resp = Response(html, status=200, content_type="text/html")
|
||||
# If still running, tell HTMX to keep polling
|
||||
if running:
|
||||
resp.headers["HX-Trigger-After-Swap"] = "test-still-running"
|
||||
return resp
|
||||
|
||||
return bp
|
||||
24
test/entrypoint.sh
Executable file
24
test/entrypoint.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# No database — skip DB wait and migrations
|
||||
|
||||
# Clear Redis page cache on deploy
|
||||
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
|
||||
python3 -c "
|
||||
import redis, os
|
||||
r = redis.from_url(os.environ['REDIS_URL'])
|
||||
r.flushdb()
|
||||
" || echo "Redis flush failed (non-fatal), continuing..."
|
||||
fi
|
||||
|
||||
# Start the app
|
||||
RELOAD_FLAG=""
|
||||
if [[ "${RELOAD:-}" == "true" ]]; then
|
||||
RELOAD_FLAG="--reload"
|
||||
fi
|
||||
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" \
|
||||
--bind 0.0.0.0:${PORT:-8000} \
|
||||
--workers ${WORKERS:-1} \
|
||||
--keep-alive 75 \
|
||||
${RELOAD_FLAG}
|
||||
9
test/path_setup.py
Normal file
9
test/path_setup.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_project_root = os.path.dirname(_app_dir)
|
||||
|
||||
for _p in (_project_root, _app_dir):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
222
test/runner.py
Normal file
222
test/runner.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Pytest subprocess runner + in-memory result storage."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# In-memory state
|
||||
_last_result: dict | None = None
|
||||
_running: bool = False
|
||||
|
||||
# Each service group runs in its own pytest subprocess with its own PYTHONPATH
|
||||
_SERVICE_GROUPS: list[dict] = [
|
||||
{"name": "shared", "dirs": ["shared/tests/", "shared/sexp/tests/"],
|
||||
"pythonpath": None},
|
||||
{"name": "blog", "dirs": ["blog/tests/"], "pythonpath": "/app/blog"},
|
||||
{"name": "market", "dirs": ["market/tests/"], "pythonpath": "/app/market"},
|
||||
{"name": "cart", "dirs": ["cart/tests/"], "pythonpath": "/app/cart"},
|
||||
{"name": "events", "dirs": ["events/tests/"], "pythonpath": "/app/events"},
|
||||
{"name": "account", "dirs": ["account/tests/"], "pythonpath": "/app/account"},
|
||||
{"name": "orders", "dirs": ["orders/tests/"], "pythonpath": "/app/orders"},
|
||||
{"name": "federation", "dirs": ["federation/tests/"],
|
||||
"pythonpath": "/app/federation"},
|
||||
{"name": "relations", "dirs": ["relations/tests/"],
|
||||
"pythonpath": "/app/relations"},
|
||||
{"name": "likes", "dirs": ["likes/tests/"], "pythonpath": "/app/likes"},
|
||||
]
|
||||
|
||||
_SERVICE_ORDER = [g["name"] for g in _SERVICE_GROUPS]
|
||||
_REPORT_PATH = "/tmp/test-report-{}.json"
|
||||
|
||||
|
||||
def _parse_report(path: str) -> tuple[list[dict], dict]:
|
||||
"""Parse a pytest-json-report file."""
|
||||
rp = Path(path)
|
||||
if not rp.exists():
|
||||
return [], {}
|
||||
try:
|
||||
report = json.loads(rp.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return [], {}
|
||||
|
||||
summary = report.get("summary", {})
|
||||
tests_raw = report.get("tests", [])
|
||||
|
||||
tests = []
|
||||
for t in tests_raw:
|
||||
tests.append({
|
||||
"nodeid": t.get("nodeid", ""),
|
||||
"outcome": t.get("outcome", "unknown"),
|
||||
"duration": round(t.get("duration", 0), 4),
|
||||
"longrepr": (t.get("call", {}) or {}).get("longrepr", ""),
|
||||
})
|
||||
return tests, summary
|
||||
|
||||
|
||||
async def _run_group(group: dict) -> tuple[list[dict], dict, str]:
|
||||
"""Run pytest for a single service group."""
|
||||
existing = [d for d in group["dirs"] if Path(f"/app/{d}").is_dir()]
|
||||
if not existing:
|
||||
return [], {}, ""
|
||||
|
||||
report_file = _REPORT_PATH.format(group["name"])
|
||||
cmd = [
|
||||
"python3", "-m", "pytest",
|
||||
*existing,
|
||||
"--json-report",
|
||||
f"--json-report-file={report_file}",
|
||||
"-q",
|
||||
"--tb=short",
|
||||
]
|
||||
env = {**os.environ}
|
||||
if group["pythonpath"]:
|
||||
env["PYTHONPATH"] = group["pythonpath"] + ":" + env.get("PYTHONPATH", "")
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
cwd="/app",
|
||||
env=env,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
stdout_str = (stdout or b"").decode("utf-8", errors="replace")
|
||||
tests, summary = _parse_report(report_file)
|
||||
return tests, summary, stdout_str
|
||||
|
||||
|
||||
async def run_tests() -> dict:
|
||||
"""Run pytest in subprocess, parse JSON report, store results."""
|
||||
global _last_result, _running
|
||||
|
||||
if _running:
|
||||
return {"status": "already_running"}
|
||||
|
||||
_running = True
|
||||
started_at = time.time()
|
||||
|
||||
try:
|
||||
tasks = [_run_group(g) for g in _SERVICE_GROUPS]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
all_tests: list[dict] = []
|
||||
total_passed = total_failed = total_errors = total_skipped = total_count = 0
|
||||
all_stdout: list[str] = []
|
||||
|
||||
for i, res in enumerate(results):
|
||||
if isinstance(res, Exception):
|
||||
log.error("Group %s failed: %s", _SERVICE_GROUPS[i]["name"], res)
|
||||
continue
|
||||
tests, summary, stdout_str = res
|
||||
all_tests.extend(tests)
|
||||
total_passed += summary.get("passed", 0)
|
||||
total_failed += summary.get("failed", 0)
|
||||
total_errors += summary.get("error", 0)
|
||||
total_skipped += summary.get("skipped", 0)
|
||||
total_count += summary.get("total", len(tests))
|
||||
if stdout_str.strip():
|
||||
all_stdout.append(stdout_str)
|
||||
|
||||
finished_at = time.time()
|
||||
status = "failed" if total_failed > 0 or total_errors > 0 else "passed"
|
||||
|
||||
_last_result = {
|
||||
"status": status,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"duration": round(finished_at - started_at, 2),
|
||||
"passed": total_passed,
|
||||
"failed": total_failed,
|
||||
"errors": total_errors,
|
||||
"skipped": total_skipped,
|
||||
"total": total_count,
|
||||
"tests": all_tests,
|
||||
"stdout": "\n".join(all_stdout)[-5000:],
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Test run complete: %s (%d passed, %d failed, %d errors, %.1fs)",
|
||||
status, total_passed, total_failed, total_errors,
|
||||
_last_result["duration"],
|
||||
)
|
||||
return _last_result
|
||||
|
||||
except Exception:
|
||||
log.exception("Test run failed")
|
||||
finished_at = time.time()
|
||||
_last_result = {
|
||||
"status": "error",
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"duration": round(finished_at - started_at, 2),
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"errors": 1,
|
||||
"skipped": 0,
|
||||
"total": 0,
|
||||
"tests": [],
|
||||
"stdout": "",
|
||||
}
|
||||
return _last_result
|
||||
finally:
|
||||
_running = False
|
||||
|
||||
|
||||
def get_results() -> dict | None:
|
||||
"""Return last run results."""
|
||||
return _last_result
|
||||
|
||||
|
||||
def get_test(nodeid: str) -> dict | None:
|
||||
"""Look up a single test by nodeid."""
|
||||
if not _last_result:
|
||||
return None
|
||||
for t in _last_result["tests"]:
|
||||
if t["nodeid"] == nodeid:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
"""Check if tests are currently running."""
|
||||
return _running
|
||||
|
||||
|
||||
def _service_from_nodeid(nodeid: str) -> str:
|
||||
"""Extract service name from a test nodeid."""
|
||||
parts = nodeid.split("/")
|
||||
return parts[0] if len(parts) >= 2 else "other"
|
||||
|
||||
|
||||
def group_tests_by_service(tests: list[dict]) -> list[dict]:
|
||||
"""Group tests into ordered sections by service."""
|
||||
buckets: dict[str, list[dict]] = OrderedDict()
|
||||
for svc in _SERVICE_ORDER:
|
||||
buckets[svc] = []
|
||||
for t in tests:
|
||||
svc = _service_from_nodeid(t["nodeid"])
|
||||
if svc not in buckets:
|
||||
buckets[svc] = []
|
||||
buckets[svc].append(t)
|
||||
|
||||
sections = []
|
||||
for svc, svc_tests in buckets.items():
|
||||
if not svc_tests:
|
||||
continue
|
||||
sections.append({
|
||||
"service": svc,
|
||||
"tests": svc_tests,
|
||||
"total": len(svc_tests),
|
||||
"passed": sum(1 for t in svc_tests if t["outcome"] == "passed"),
|
||||
"failed": sum(1 for t in svc_tests if t["outcome"] == "failed"),
|
||||
"errors": sum(1 for t in svc_tests if t["outcome"] == "error"),
|
||||
"skipped": sum(1 for t in svc_tests if t["outcome"] == "skipped"),
|
||||
})
|
||||
return sections
|
||||
6
test/services/__init__.py
Normal file
6
test/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Test app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the test app (none needed)."""
|
||||
0
test/sexp/__init__.py
Normal file
0
test/sexp/__init__.py
Normal file
113
test/sexp/dashboard.sexpr
Normal file
113
test/sexp/dashboard.sexpr
Normal file
@@ -0,0 +1,113 @@
|
||||
;; Test dashboard components
|
||||
|
||||
(defcomp ~test-status-badge (&key status)
|
||||
(span :class (str "inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium "
|
||||
(if (= status "running") "border-amber-300 bg-amber-50 text-amber-700 animate-pulse"
|
||||
(if (= status "passed") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
(if (= status "failed") "border-rose-300 bg-rose-50 text-rose-700"
|
||||
"border-stone-300 bg-stone-50 text-stone-700"))))
|
||||
status))
|
||||
|
||||
(defcomp ~test-run-button (&key running csrf)
|
||||
(form :method "POST" :action "/run" :class "inline"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(button :type "submit"
|
||||
:class (str "rounded bg-stone-800 px-4 py-2 text-sm font-medium text-white hover:bg-stone-700 "
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors")
|
||||
:disabled (if running "true" nil)
|
||||
(if running "Running..." "Run Tests"))))
|
||||
|
||||
(defcomp ~test-filter-card (&key href label count colour-border colour-bg colour-text active)
|
||||
(a :href href
|
||||
:hx-get href
|
||||
:hx-target "#main-panel"
|
||||
:hx-select "#main-panel"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-push-url "true"
|
||||
:class (str "block rounded border p-3 text-center transition-colors no-underline hover:opacity-80 "
|
||||
colour-border " " colour-bg " "
|
||||
(if active "ring-2 ring-offset-1 ring-stone-500 " ""))
|
||||
(div :class (str "text-3xl font-bold " colour-text) count)
|
||||
(div :class (str "text-sm " colour-text) label)))
|
||||
|
||||
(defcomp ~test-summary (&key status passed failed errors skipped total duration last-run running csrf active-filter)
|
||||
(div :class "space-y-4"
|
||||
(div :class "flex items-center justify-between flex-wrap gap-3"
|
||||
(div :class "flex items-center gap-3"
|
||||
(h2 :class "text-2xl font-semibold text-stone-800" "Test Results")
|
||||
(when status (~test-status-badge :status status)))
|
||||
(~test-run-button :running running :csrf csrf))
|
||||
(when status
|
||||
(div :class "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3"
|
||||
(~test-filter-card :href "/" :label "Total" :count total
|
||||
:colour-border "border-stone-200" :colour-bg "bg-white"
|
||||
:colour-text "text-stone-800"
|
||||
:active (if (= active-filter nil) "true" nil))
|
||||
(~test-filter-card :href "/?filter=passed" :label "Passed" :count passed
|
||||
:colour-border "border-emerald-200" :colour-bg "bg-emerald-50"
|
||||
:colour-text "text-emerald-700"
|
||||
:active (if (= active-filter "passed") "true" nil))
|
||||
(~test-filter-card :href "/?filter=failed" :label "Failed" :count failed
|
||||
:colour-border "border-rose-200" :colour-bg "bg-rose-50"
|
||||
:colour-text "text-rose-700"
|
||||
:active (if (= active-filter "failed") "true" nil))
|
||||
(~test-filter-card :href "/?filter=errors" :label "Errors" :count errors
|
||||
:colour-border "border-orange-200" :colour-bg "bg-orange-50"
|
||||
:colour-text "text-orange-700"
|
||||
:active (if (= active-filter "errors") "true" nil))
|
||||
(~test-filter-card :href "/?filter=skipped" :label "Skipped" :count skipped
|
||||
:colour-border "border-sky-200" :colour-bg "bg-sky-50"
|
||||
:colour-text "text-sky-700"
|
||||
:active (if (= active-filter "skipped") "true" nil))
|
||||
(~test-filter-card :href "/" :label "Duration" :count (str duration "s")
|
||||
:colour-border "border-stone-200" :colour-bg "bg-white"
|
||||
:colour-text "text-stone-800" :active nil))
|
||||
(div :class "text-sm text-stone-400" (str "Last run: " last-run)))))
|
||||
|
||||
(defcomp ~test-service-header (&key service total passed failed)
|
||||
(tr :class "border-b-2 border-stone-300 bg-stone-100"
|
||||
(td :class "px-3 py-2 text-sm font-bold text-stone-700" :colspan "4"
|
||||
(span service)
|
||||
(span :class "ml-2 text-xs font-normal text-stone-500"
|
||||
(str total " tests, " passed " passed, " failed " failed")))))
|
||||
|
||||
(defcomp ~test-row (&key nodeid outcome duration longrepr)
|
||||
(tr :class (str "border-b border-stone-100 "
|
||||
(if (= outcome "passed") "bg-white"
|
||||
(if (= outcome "failed") "bg-rose-50"
|
||||
(if (= outcome "skipped") "bg-sky-50"
|
||||
"bg-orange-50"))))
|
||||
(td :class "px-3 py-2 text-sm font-mono text-stone-700 max-w-0 truncate" :title nodeid nodeid)
|
||||
(td :class "px-3 py-2 text-center"
|
||||
(span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium "
|
||||
(if (= outcome "passed") "border-emerald-300 bg-emerald-50 text-emerald-700"
|
||||
(if (= outcome "failed") "border-rose-300 bg-rose-50 text-rose-700"
|
||||
(if (= outcome "skipped") "border-sky-300 bg-sky-50 text-sky-700"
|
||||
"border-orange-300 bg-orange-50 text-orange-700"))))
|
||||
outcome))
|
||||
(td :class "px-3 py-2 text-right text-sm text-stone-500 tabular-nums" (str duration "s"))
|
||||
(td :class "px-3 py-2 text-sm text-rose-600 font-mono max-w-xs truncate" :title longrepr
|
||||
(when longrepr longrepr))))
|
||||
|
||||
(defcomp ~test-results-table (&key rows-html has-failures)
|
||||
(div :class "overflow-x-auto rounded border border-stone-200 bg-white"
|
||||
(table :class "w-full text-left"
|
||||
(thead
|
||||
(tr :class "border-b border-stone-200 bg-stone-50"
|
||||
(th :class "px-3 py-2 text-sm font-medium text-stone-600" "Test")
|
||||
(th :class "px-3 py-2 text-xs font-medium text-stone-600 text-center w-24" "Status")
|
||||
(th :class "px-3 py-2 text-xs font-medium text-stone-600 text-right w-20" "Time")
|
||||
(th :class "px-3 py-2 text-xs font-medium text-stone-600 w-48" "Error")))
|
||||
(tbody (raw! rows-html)))))
|
||||
|
||||
(defcomp ~test-running-indicator ()
|
||||
(div :class "flex items-center justify-center py-12 text-stone-500"
|
||||
(div :class "flex items-center gap-3"
|
||||
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full")
|
||||
(span :class "text-sm" "Running tests..."))))
|
||||
|
||||
(defcomp ~test-no-results ()
|
||||
(div :class "flex items-center justify-center py-12 text-stone-400"
|
||||
(div :class "text-center"
|
||||
(div :class "text-4xl mb-2" "?")
|
||||
(div :class "text-sm" "No test results yet. Click Run Tests to start."))))
|
||||
208
test/sexp/sexp_components.py
Normal file
208
test/sexp/sexp_components.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Test service s-expression page components."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from shared.sexp.jinja_bridge import render, load_service_components
|
||||
from shared.sexp.helpers import root_header_html, full_page
|
||||
|
||||
# Load test-specific .sexpr components at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
def _format_time(ts: float | None) -> str:
|
||||
"""Format a unix timestamp for display."""
|
||||
if not ts:
|
||||
return "never"
|
||||
return datetime.fromtimestamp(ts).strftime("%-d %b %Y, %H:%M:%S")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Menu / header
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FILTER_MAP = {
|
||||
"passed": "passed",
|
||||
"failed": "failed",
|
||||
"errors": "error",
|
||||
"skipped": "skipped",
|
||||
}
|
||||
|
||||
|
||||
def _test_header_html(ctx: dict, active_service: str | None = None) -> str:
|
||||
"""Build the Tests menu-row (level 1) with service nav links."""
|
||||
nav = _service_nav_html(ctx, active_service)
|
||||
return render(
|
||||
"menu-row",
|
||||
id="test-row", level=1, colour="sky",
|
||||
link_href="/", link_label="Tests", icon="fa fa-flask",
|
||||
nav_html=nav,
|
||||
child_id="test-header-child",
|
||||
)
|
||||
|
||||
|
||||
def _service_nav_html(ctx: dict, active_service: str | None = None) -> str:
|
||||
"""Render service filter nav links using ~nav-link component."""
|
||||
from runner import _SERVICE_ORDER
|
||||
parts = []
|
||||
# "All" link
|
||||
parts.append(render(
|
||||
"nav-link",
|
||||
href="/",
|
||||
label="all",
|
||||
is_selected="true" if not active_service else None,
|
||||
select_colours="aria-selected:bg-sky-200 aria-selected:text-sky-900",
|
||||
))
|
||||
for svc in _SERVICE_ORDER:
|
||||
parts.append(render(
|
||||
"nav-link",
|
||||
href=f"/?service={svc}",
|
||||
label=svc,
|
||||
is_selected="true" if active_service == svc else None,
|
||||
select_colours="aria-selected:bg-sky-200 aria-selected:text-sky-900",
|
||||
))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _header_stack_html(ctx: dict, active_service: str | None = None) -> str:
|
||||
"""Full header stack: root row + tests child row."""
|
||||
hdr = root_header_html(ctx)
|
||||
inner = _test_header_html(ctx, active_service)
|
||||
hdr += render("header-child", inner_html=inner)
|
||||
return hdr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test rows / grouping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _test_rows_html(tests: list[dict]) -> str:
|
||||
"""Render all test result rows."""
|
||||
parts = []
|
||||
for t in tests:
|
||||
parts.append(render(
|
||||
"test-row",
|
||||
nodeid=t["nodeid"],
|
||||
outcome=t["outcome"],
|
||||
duration=str(t["duration"]),
|
||||
longrepr=t.get("longrepr", ""),
|
||||
))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _grouped_rows_html(tests: list[dict]) -> str:
|
||||
"""Render test rows grouped by service with section headers."""
|
||||
from runner import group_tests_by_service
|
||||
sections = group_tests_by_service(tests)
|
||||
parts = []
|
||||
for sec in sections:
|
||||
parts.append(render(
|
||||
"test-service-header",
|
||||
service=sec["service"],
|
||||
total=str(sec["total"]),
|
||||
passed=str(sec["passed"]),
|
||||
failed=str(sec["failed"]),
|
||||
))
|
||||
parts.append(_test_rows_html(sec["tests"]))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _filter_tests(tests: list[dict], active_filter: str | None,
|
||||
active_service: str | None) -> list[dict]:
|
||||
"""Filter tests by outcome and/or service."""
|
||||
from runner import _service_from_nodeid
|
||||
filtered = tests
|
||||
if active_filter and active_filter in _FILTER_MAP:
|
||||
outcome = _FILTER_MAP[active_filter]
|
||||
filtered = [t for t in filtered if t["outcome"] == outcome]
|
||||
if active_service:
|
||||
filtered = [t for t in filtered if _service_from_nodeid(t["nodeid"]) == active_service]
|
||||
return filtered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Results partial
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _results_partial_html(result: dict | None, running: bool, csrf: str,
|
||||
active_filter: str | None = None,
|
||||
active_service: str | None = None) -> str:
|
||||
"""Render the results section (summary + table or running indicator)."""
|
||||
if running and not result:
|
||||
summary = render(
|
||||
"test-summary",
|
||||
status="running", passed="0", failed="0", errors="0",
|
||||
skipped="0", total="0", duration="...",
|
||||
last_run="in progress", running=True, csrf=csrf,
|
||||
active_filter=active_filter,
|
||||
)
|
||||
return summary + render("test-running-indicator")
|
||||
|
||||
if not result:
|
||||
summary = render(
|
||||
"test-summary",
|
||||
status=None, passed="0", failed="0", errors="0",
|
||||
skipped="0", total="0", duration="0",
|
||||
last_run="never", running=running, csrf=csrf,
|
||||
active_filter=active_filter,
|
||||
)
|
||||
return summary + render("test-no-results")
|
||||
|
||||
status = "running" if running else result["status"]
|
||||
summary = render(
|
||||
"test-summary",
|
||||
status=status,
|
||||
passed=str(result["passed"]),
|
||||
failed=str(result["failed"]),
|
||||
errors=str(result["errors"]),
|
||||
skipped=str(result.get("skipped", 0)),
|
||||
total=str(result["total"]),
|
||||
duration=str(result["duration"]),
|
||||
last_run=_format_time(result["finished_at"]) if not running else "in progress",
|
||||
running=running,
|
||||
csrf=csrf,
|
||||
active_filter=active_filter,
|
||||
)
|
||||
|
||||
if running:
|
||||
return summary + render("test-running-indicator")
|
||||
|
||||
tests = result.get("tests", [])
|
||||
tests = _filter_tests(tests, active_filter, active_service)
|
||||
if not tests:
|
||||
return summary + render("test-no-results")
|
||||
|
||||
has_failures = result["failed"] > 0 or result["errors"] > 0
|
||||
rows = _grouped_rows_html(tests)
|
||||
table = render("test-results-table", rows_html=rows,
|
||||
has_failures=str(has_failures).lower())
|
||||
return summary + table
|
||||
|
||||
|
||||
def _wrap_results_div(inner_html: str, running: bool) -> str:
|
||||
"""Wrap results in a div with HTMX polling when running."""
|
||||
attrs = 'id="test-results" class="space-y-6 p-4"'
|
||||
if running:
|
||||
attrs += ' hx-get="/results" hx-trigger="every 2s" hx-swap="outerHTML"'
|
||||
return f'<div {attrs}>{inner_html}</div>'
|
||||
|
||||
|
||||
async def render_dashboard_page(ctx: dict, result: dict | None,
|
||||
running: bool, csrf: str,
|
||||
active_filter: str | None = None,
|
||||
active_service: str | None = None) -> str:
|
||||
"""Full page: test dashboard."""
|
||||
hdr = _header_stack_html(ctx, active_service)
|
||||
inner = _results_partial_html(result, running, csrf, active_filter, active_service)
|
||||
content = _wrap_results_div(inner, running)
|
||||
return full_page(ctx, header_rows_html=hdr, content_html=content)
|
||||
|
||||
|
||||
async def render_results_partial(result: dict | None, running: bool,
|
||||
csrf: str,
|
||||
active_filter: str | None = None,
|
||||
active_service: str | None = None) -> str:
|
||||
"""HTMX partial: just the results section (wrapped in polling div)."""
|
||||
inner = _results_partial_html(result, running, csrf, active_filter, active_service)
|
||||
return _wrap_results_div(inner, running)
|
||||
Reference in New Issue
Block a user