Compare commits
3 Commits
293f7713d6
...
sx
| Author | SHA1 | Date | |
|---|---|---|---|
| 03f0929fdf | |||
| f551fc7453 | |||
| e30cb0a992 |
@@ -8,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_account_bp, register_auth_bp, register_fragments
|
||||
from bp import register_account_bp, register_auth_bp
|
||||
|
||||
|
||||
async def account_context() -> dict:
|
||||
@@ -86,7 +86,8 @@ def create_app() -> "Quart":
|
||||
from shared.sx.pages import auto_mount_pages
|
||||
auto_mount_pages(app, "account")
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "account")
|
||||
|
||||
from bp.actions.routes import register as register_actions
|
||||
app.register_blueprint(register_actions())
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
from .account.routes import register as register_account_bp
|
||||
from .auth.routes import register as register_auth_bp
|
||||
from .fragments import register_fragments
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Account app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``account/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("account", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "account", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -16,7 +16,6 @@ from bp import (
|
||||
register_admin,
|
||||
register_menu_items,
|
||||
register_snippets,
|
||||
register_fragments,
|
||||
register_data,
|
||||
register_actions,
|
||||
)
|
||||
@@ -108,7 +107,9 @@ def create_app() -> "Quart":
|
||||
app.register_blueprint(register_admin("/settings"))
|
||||
app.register_blueprint(register_menu_items())
|
||||
app.register_blueprint(register_snippets())
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "blog")
|
||||
|
||||
app.register_blueprint(register_data())
|
||||
app.register_blueprint(register_actions())
|
||||
|
||||
|
||||
@@ -2,6 +2,5 @@ from .blog.routes import register as register_blog_bp
|
||||
from .admin.routes import register as register_admin
|
||||
from .menu_items.routes import register as register_menu_items
|
||||
from .snippets.routes import register as register_snippets
|
||||
from .fragments import register_fragments
|
||||
from .data import register_data
|
||||
from .actions.routes import register as register_actions
|
||||
|
||||
@@ -126,7 +126,7 @@ _CARD_MARKER_RE = re.compile(
|
||||
def _parse_card_fragments(html: str) -> dict[str, str]:
|
||||
"""Parse the container-cards fragment into {post_id_str: html} dict."""
|
||||
result = {}
|
||||
for m in _CARD_MARKER_RE.finditer(html):
|
||||
for m in _CARD_MARKER_RE.finditer(str(html)):
|
||||
post_id_str = m.group(1)
|
||||
inner = m.group(2).strip()
|
||||
if inner:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Blog app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``blog/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("blog", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "blog", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -17,7 +17,6 @@ from bp import (
|
||||
register_page_cart,
|
||||
register_cart_global,
|
||||
register_page_admin,
|
||||
register_fragments,
|
||||
register_actions,
|
||||
register_data,
|
||||
register_inbox,
|
||||
@@ -141,7 +140,9 @@ def create_app() -> "Quart":
|
||||
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
|
||||
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "cart")
|
||||
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
app.register_blueprint(register_inbox())
|
||||
|
||||
@@ -2,7 +2,6 @@ from .cart.overview_routes import register as register_cart_overview
|
||||
from .cart.page_routes import register as register_page_cart
|
||||
from .cart.global_routes import register as register_cart_global
|
||||
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
|
||||
from .inbox import register_inbox
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Cart app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``cart/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("cart", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "cart", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -9,7 +9,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_fragments, register_actions, register_data
|
||||
from bp import register_all_events, register_calendar, register_calendars, register_markets, register_page, register_actions, register_data
|
||||
|
||||
|
||||
async def events_context() -> dict:
|
||||
@@ -112,7 +112,9 @@ def create_app() -> "Quart":
|
||||
url_prefix="/<slug>/markets",
|
||||
)
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "events")
|
||||
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
|
||||
|
||||
@@ -3,6 +3,5 @@ from .calendar.routes import register as register_calendar
|
||||
from .calendars.routes import register as register_calendars
|
||||
from .markets.routes import register as register_markets
|
||||
from .page.routes import register as register_page
|
||||
from .fragments import register_fragments
|
||||
from .actions import register_actions
|
||||
from .data import register_data
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,104 +0,0 @@
|
||||
"""Events app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``events/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
|
||||
container-cards and account-page remain as Python handlers because they
|
||||
call domain service methods and return batched/conditional content, but
|
||||
they use sx_call() for rendering (no Jinja templates).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, g, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.services.registry import services
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
# Fragment types that return HTML (comment-delimited batch)
|
||||
_html_types = {"container-cards"}
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
# 1. Check Python handlers first
|
||||
handler = _handlers.get(fragment_type)
|
||||
if handler is not None:
|
||||
result = await handler()
|
||||
ct = "text/html" if fragment_type in _html_types else "text/sx"
|
||||
return Response(result, status=200, content_type=ct)
|
||||
|
||||
# 2. Check sx handler registry
|
||||
handler_def = get_handler("events", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "events", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
# --- container-cards fragment: entries for blog listing cards -----------
|
||||
# Returns text/html with <!-- card-widget:POST_ID --> comment markers
|
||||
# so the blog consumer can split per-post fragments.
|
||||
|
||||
async def _container_cards_handler():
|
||||
from sx.sx_components import render_fragment_container_cards
|
||||
|
||||
post_ids_raw = request.args.get("post_ids", "")
|
||||
post_slugs_raw = request.args.get("post_slugs", "")
|
||||
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
|
||||
post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()]
|
||||
if not post_ids:
|
||||
return ""
|
||||
|
||||
slug_map = {}
|
||||
for i, pid in enumerate(post_ids):
|
||||
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
|
||||
|
||||
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
|
||||
return render_fragment_container_cards(batch, post_ids, slug_map)
|
||||
|
||||
_handlers["container-cards"] = _container_cards_handler
|
||||
|
||||
# --- account-page fragment: tickets or bookings panel ------------------
|
||||
# Returns text/sx — the account app embeds this as sx source.
|
||||
|
||||
async def _account_page_handler():
|
||||
from sx.sx_components import (
|
||||
render_fragment_account_tickets,
|
||||
render_fragment_account_bookings,
|
||||
)
|
||||
|
||||
slug = request.args.get("slug", "")
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
if not user_id:
|
||||
return ""
|
||||
|
||||
if slug == "tickets":
|
||||
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
|
||||
return render_fragment_account_tickets(tickets)
|
||||
elif slug == "bookings":
|
||||
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
|
||||
return render_fragment_account_bookings(bookings)
|
||||
return ""
|
||||
|
||||
_handlers["account-page"] = _account_page_handler
|
||||
|
||||
bp._fragment_handlers = _handlers
|
||||
|
||||
return bp
|
||||
49
events/sx/handlers/account-page.sx
Normal file
49
events/sx/handlers/account-page.sx
Normal file
@@ -0,0 +1,49 @@
|
||||
;; Account-page fragment handler
|
||||
;;
|
||||
;; Renders tickets or bookings panel for the account dashboard.
|
||||
;; slug=tickets → ticket list; slug=bookings → booking list.
|
||||
|
||||
(defhandler account-page (&key slug user_id)
|
||||
(let ((uid (parse-int (or user_id "0"))))
|
||||
(when (> uid 0)
|
||||
(cond
|
||||
(= slug "tickets")
|
||||
(let ((tickets (service "calendar" "user-tickets" :user-id uid)))
|
||||
(~events-frag-tickets-panel
|
||||
:items (if (empty? tickets)
|
||||
(~empty-state :message "No tickets yet."
|
||||
:cls "text-sm text-stone-500")
|
||||
(~events-frag-tickets-list
|
||||
:items (<> (map (fn (t)
|
||||
(~events-frag-ticket-item
|
||||
:href (app-url "events"
|
||||
(str "/tickets/" (get t "code") "/"))
|
||||
:entry-name (get t "entry_name")
|
||||
:date-str (format-date (get t "entry_start_at") "%d %b %Y, %H:%M")
|
||||
:calendar-name (when (get t "calendar_name")
|
||||
(span (str "\u00b7 " (get t "calendar_name"))))
|
||||
:type-name (when (get t "ticket_type_name")
|
||||
(span (str "\u00b7 " (get t "ticket_type_name"))))
|
||||
:badge (~status-pill :status (or (get t "state") ""))))
|
||||
tickets))))))
|
||||
|
||||
(= slug "bookings")
|
||||
(let ((bookings (service "calendar" "user-bookings" :user-id uid)))
|
||||
(~events-frag-bookings-panel
|
||||
:items (if (empty? bookings)
|
||||
(~empty-state :message "No bookings yet."
|
||||
:cls "text-sm text-stone-500")
|
||||
(~events-frag-bookings-list
|
||||
:items (<> (map (fn (b)
|
||||
(~events-frag-booking-item
|
||||
:name (get b "name")
|
||||
:date-str (str (format-date (get b "start_at") "%d %b %Y, %H:%M")
|
||||
(if (get b "end_at")
|
||||
(str " \u2013 " (format-date (get b "end_at") "%H:%M"))
|
||||
""))
|
||||
:calendar-name (when (get b "calendar_name")
|
||||
(span (str "\u00b7 " (get b "calendar_name"))))
|
||||
:cost-str (when (get b "cost")
|
||||
(span (str "\u00b7 \u00a3" (get b "cost"))))
|
||||
:badge (~status-pill :status (or (get b "state") ""))))
|
||||
bookings))))))))))
|
||||
38
events/sx/handlers/container-cards.sx
Normal file
38
events/sx/handlers/container-cards.sx
Normal file
@@ -0,0 +1,38 @@
|
||||
;; Container-cards fragment handler
|
||||
;;
|
||||
;; Returns HTML with <!-- card-widget:ID --> comment markers so the
|
||||
;; blog consumer can split per-post fragments. Each post section
|
||||
;; contains an events-frag-entries-widget with entry cards.
|
||||
|
||||
(defhandler container-cards (&key post_ids post_slugs)
|
||||
(let ((ids (filter (fn (x) (> x 0))
|
||||
(map parse-int
|
||||
(filter (fn (s) (not (empty? s)))
|
||||
(split (or post_ids "") ",")))))
|
||||
(slugs (map trim
|
||||
(split (or post_slugs "") ","))))
|
||||
(when (not (empty? ids))
|
||||
(let ((batch (service "calendar" "confirmed-entries-for-posts" :post-ids ids)))
|
||||
(<> (map-indexed (fn (i pid)
|
||||
(let ((entries (or (get batch pid) (list)))
|
||||
(post-slug (or (nth slugs i) "")))
|
||||
(<> (str "<!-- card-widget:" pid " -->")
|
||||
(when (not (empty? entries))
|
||||
(~events-frag-entries-widget
|
||||
:cards (<> (map (fn (e)
|
||||
(let ((time-str (str (format-date (get e "start_at") "%H:%M")
|
||||
(if (get e "end_at")
|
||||
(str " \u2013 " (format-date (get e "end_at") "%H:%M"))
|
||||
""))))
|
||||
(~events-frag-entry-card
|
||||
:href (app-url "events"
|
||||
(str "/" post-slug
|
||||
"/" (get e "calendar_slug")
|
||||
"/" (get e "start_at_year")
|
||||
"/" (get e "start_at_month")
|
||||
"/" (get e "start_at_day")
|
||||
"/entries/" (get e "id") "/"))
|
||||
:name (get e "name")
|
||||
:date-str (format-date (get e "start_at") "%a, %b %d")
|
||||
:time-str time-str))) entries))))
|
||||
(str "<!-- /card-widget:" pid " -->")))) ids))))))
|
||||
@@ -12,7 +12,6 @@ from shared.services.registry import services
|
||||
from bp import (
|
||||
register_identity_bp,
|
||||
register_social_bp,
|
||||
register_fragments,
|
||||
)
|
||||
|
||||
|
||||
@@ -99,7 +98,8 @@ def create_app() -> "Quart":
|
||||
from shared.sx.pages import auto_mount_pages
|
||||
auto_mount_pages(app, "federation")
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "federation")
|
||||
|
||||
# --- home page ---
|
||||
@app.get("/")
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
from .identity.routes import register as register_identity_bp
|
||||
from .social.routes import register as register_social_bp
|
||||
from .fragments import register_fragments
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Federation app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``federation/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("federation", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "federation", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -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_page_admin, register_fragments, register_actions, register_data
|
||||
from bp import register_market_bp, register_all_markets, register_page_markets, register_page_admin, register_actions, register_data
|
||||
|
||||
|
||||
async def market_context() -> dict:
|
||||
@@ -126,7 +126,9 @@ def create_app() -> "Quart":
|
||||
url_prefix="/<page_slug>/<market_slug>",
|
||||
)
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "market")
|
||||
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
|
||||
|
||||
@@ -3,6 +3,5 @@ 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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .routes import register as register_fragments
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Market app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``market/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("market", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "market", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -14,7 +14,6 @@ from bp import (
|
||||
register_orders,
|
||||
register_order,
|
||||
register_checkout,
|
||||
register_fragments,
|
||||
register_actions,
|
||||
register_data,
|
||||
)
|
||||
@@ -77,7 +76,9 @@ def create_app() -> "Quart":
|
||||
from sxc.pages import setup_orders_pages
|
||||
setup_orders_pages()
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "orders")
|
||||
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
|
||||
|
||||
@@ -3,4 +3,3 @@ from .orders.routes import register as register_orders
|
||||
from .checkout.routes import register as register_checkout
|
||||
from .data.routes import register as register_data
|
||||
from .actions.routes import register as register_actions
|
||||
from .fragments.routes import register as register_fragments
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Orders app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``orders/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("orders", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "orders", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -4,7 +4,7 @@ import sx.sx_components as sx_components # noqa: F401 # ensure Hypercorn --rel
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
|
||||
from bp import register_actions, register_data, register_fragments
|
||||
from bp import register_actions, register_data
|
||||
from services import register_domain_services
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ def create_app() -> "Quart":
|
||||
|
||||
app.register_blueprint(register_actions())
|
||||
app.register_blueprint(register_data())
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
from shared.sx.handlers import auto_mount_fragment_handlers
|
||||
auto_mount_fragment_handlers(app, "relations")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
from .data.routes import register as register_data
|
||||
from .actions.routes import register as register_actions
|
||||
from .fragments.routes import register as register_fragments
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Relations app fragment endpoints.
|
||||
|
||||
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
|
||||
All handlers are defined declaratively in .sx files under
|
||||
``relations/sx/handlers/`` and dispatched via the sx handler registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.sx.handlers import get_handler, execute_handler
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler_def = get_handler("relations", fragment_type)
|
||||
if handler_def is not None:
|
||||
result = await execute_handler(
|
||||
handler_def, "relations", args=dict(request.args),
|
||||
)
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
return bp
|
||||
@@ -542,8 +542,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.addEventListener("sx:responseError", function (event) {
|
||||
var resp = event.detail.response;
|
||||
if (!resp) return;
|
||||
var status = resp.status || 0;
|
||||
// Don't show error modal when sx-retry will handle the failure
|
||||
var triggerEl = event.target;
|
||||
if (triggerEl && triggerEl.getAttribute("sx-retry")) return;
|
||||
var status = resp.status || 0;
|
||||
var form = triggerEl ? triggerEl.closest("form") : null;
|
||||
|
||||
var title = "Something went wrong";
|
||||
|
||||
@@ -1685,9 +1685,17 @@
|
||||
// ---- Request executor -------------------------------------------------
|
||||
|
||||
function executeRequest(el, verbInfo, extraParams) {
|
||||
// Re-read verb from element in case attributes were morphed since binding
|
||||
var currentVerb = getVerb(el);
|
||||
if (currentVerb) verbInfo = currentVerb;
|
||||
var method = verbInfo.method;
|
||||
var url = verbInfo.url;
|
||||
|
||||
// Reset retry backoff on fresh (non-retry) requests
|
||||
if (!el.classList.contains("sx-error")) {
|
||||
el.removeAttribute("data-sx-retry-ms");
|
||||
}
|
||||
|
||||
// sx-media: skip if media query doesn't match
|
||||
var media = el.getAttribute("sx-media");
|
||||
if (media && !window.matchMedia(media).matches) return Promise.resolve();
|
||||
|
||||
@@ -252,6 +252,8 @@ def _css_selector_to_class(selector: str) -> str:
|
||||
i += 2
|
||||
elif name[i] == ':':
|
||||
break # pseudo-class — stop here
|
||||
elif name[i] == '[':
|
||||
break # attribute selector — stop here
|
||||
else:
|
||||
result.append(name[i])
|
||||
i += 1
|
||||
|
||||
@@ -204,3 +204,42 @@ def create_handler_blueprint(service_name: str) -> Any:
|
||||
bp._python_handlers = _python_handlers # type: ignore[attr-defined]
|
||||
|
||||
return bp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct app mount — replaces per-service fragment blueprint boilerplate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def auto_mount_fragment_handlers(app: Any, service_name: str) -> Callable:
|
||||
"""Mount ``/internal/fragments/<type>`` directly on the app.
|
||||
|
||||
Returns an ``add_handler(name, fn, content_type)`` function for
|
||||
registering Python handler overrides (checked before SX handlers).
|
||||
"""
|
||||
from quart import Response, request
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
|
||||
python_handlers: dict[str, Callable[[], Awaitable[str]]] = {}
|
||||
html_types: set[str] = set()
|
||||
|
||||
@app.get("/internal/fragments/<fragment_type>")
|
||||
async def _fragment_dispatch(fragment_type: str):
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
py = python_handlers.get(fragment_type)
|
||||
if py is not None:
|
||||
result = await py()
|
||||
ct = "text/html" if fragment_type in html_types else "text/sx"
|
||||
return Response(result, status=200, content_type=ct)
|
||||
hdef = get_handler(service_name, fragment_type)
|
||||
if hdef is not None:
|
||||
result = await execute_handler(hdef, service_name, args=dict(request.args))
|
||||
return Response(result, status=200, content_type="text/sx")
|
||||
return Response("", status=200, content_type="text/sx")
|
||||
|
||||
def add_handler(name: str, fn: Callable[[], Awaitable[str]], content_type: str = "text/sx") -> None:
|
||||
python_handlers[name] = fn
|
||||
if content_type == "text/html":
|
||||
html_types.add(name)
|
||||
|
||||
return add_handler
|
||||
|
||||
@@ -242,6 +242,8 @@ def _convert_result(result: Any) -> Any:
|
||||
if result is None:
|
||||
from .types import NIL
|
||||
return NIL
|
||||
if isinstance(result, dict):
|
||||
return {k: _convert_result(v) for k, v in result.items()}
|
||||
if isinstance(result, tuple):
|
||||
# Tuple returns (e.g. (entries, has_more)) → list for sx access
|
||||
return [_convert_result(item) for item in result]
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
(when auth-menu auth-menu))))
|
||||
|
||||
; @css bg-sky-400 bg-sky-300 bg-sky-200 bg-sky-100 bg-violet-400 bg-violet-300 bg-violet-200 bg-violet-100
|
||||
; @css aria-selected:bg-violet-200 aria-selected:text-violet-900 aria-selected:bg-stone-500 aria-selected:text-white
|
||||
(defcomp ~menu-row-sx (&key id level colour link-href link-label link-label-content icon
|
||||
selected hx-select nav child-id child oob external)
|
||||
(let* ((c (or colour "sky"))
|
||||
|
||||
443
shared/tests/test_sx_app_pages.py
Normal file
443
shared/tests/test_sx_app_pages.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""Integration tests for SX docs app — page rendering + interactive API endpoints.
|
||||
|
||||
Runs inside the test container, hitting the sx_docs service over the internal
|
||||
network. Uses ``SX-Request: true`` header to bypass the silent-SSO OAuth
|
||||
redirect on page requests.
|
||||
|
||||
Tested:
|
||||
- All 27 example pages render with 200 and contain meaningful content
|
||||
- All 23 attribute detail pages render and mention the attribute name
|
||||
- All 35+ interactive API endpoints return 200 with expected content
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
SX_BASE = os.environ.get("INTERNAL_URL_SX", "http://sx_docs:8000")
|
||||
HEADERS = {"SX-Request": "true"}
|
||||
TIMEOUT = 15.0
|
||||
|
||||
|
||||
def _get(path: str, **kw) -> httpx.Response:
|
||||
return httpx.get(
|
||||
f"{SX_BASE}{path}",
|
||||
headers=HEADERS,
|
||||
timeout=TIMEOUT,
|
||||
follow_redirects=True,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _post(path: str, **kw) -> httpx.Response:
|
||||
return httpx.post(
|
||||
f"{SX_BASE}{path}",
|
||||
headers=HEADERS,
|
||||
timeout=TIMEOUT,
|
||||
follow_redirects=True,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _put(path: str, **kw) -> httpx.Response:
|
||||
return httpx.put(
|
||||
f"{SX_BASE}{path}",
|
||||
headers=HEADERS,
|
||||
timeout=TIMEOUT,
|
||||
follow_redirects=True,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _patch(path: str, **kw) -> httpx.Response:
|
||||
return httpx.patch(
|
||||
f"{SX_BASE}{path}",
|
||||
headers=HEADERS,
|
||||
timeout=TIMEOUT,
|
||||
follow_redirects=True,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def _delete(path: str, **kw) -> httpx.Response:
|
||||
return httpx.delete(
|
||||
f"{SX_BASE}{path}",
|
||||
headers=HEADERS,
|
||||
timeout=TIMEOUT,
|
||||
follow_redirects=True,
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
# ── Check that the sx_docs service is reachable ──────────────────────────
|
||||
|
||||
def _sx_reachable() -> bool:
|
||||
try:
|
||||
r = httpx.get(f"{SX_BASE}/", timeout=5, follow_redirects=False)
|
||||
return r.status_code in (200, 302)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _sx_reachable(),
|
||||
reason=f"sx_docs service not reachable at {SX_BASE}",
|
||||
)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Example pages — rendering
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
EXAMPLES = [
|
||||
"click-to-load",
|
||||
"form-submission",
|
||||
"polling",
|
||||
"delete-row",
|
||||
"inline-edit",
|
||||
"oob-swaps",
|
||||
"lazy-loading",
|
||||
"infinite-scroll",
|
||||
"progress-bar",
|
||||
"active-search",
|
||||
"inline-validation",
|
||||
"value-select",
|
||||
"reset-on-submit",
|
||||
"edit-row",
|
||||
"bulk-update",
|
||||
"swap-positions",
|
||||
"select-filter",
|
||||
"tabs",
|
||||
"animations",
|
||||
"dialogs",
|
||||
"keyboard-shortcuts",
|
||||
"put-patch",
|
||||
"json-encoding",
|
||||
"vals-and-headers",
|
||||
"loading-states",
|
||||
"sync-replace",
|
||||
"retry",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", EXAMPLES)
|
||||
def test_example_page_renders(slug: str):
|
||||
"""Each example page must render successfully on two consecutive loads."""
|
||||
for attempt in (1, 2):
|
||||
r = _get(f"/examples/{slug}")
|
||||
assert r.status_code == 200, (
|
||||
f"/examples/{slug} returned {r.status_code} on attempt {attempt}"
|
||||
)
|
||||
assert len(r.text) > 500, (
|
||||
f"/examples/{slug} response too short ({len(r.text)} bytes) on attempt {attempt}"
|
||||
)
|
||||
# Every example page should have a demo section
|
||||
assert "demo" in r.text.lower() or "example" in r.text.lower(), (
|
||||
f"/examples/{slug} missing demo/example content"
|
||||
)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Attribute detail pages — rendering
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ATTRIBUTES = [
|
||||
"sx-get",
|
||||
"sx-post",
|
||||
"sx-put",
|
||||
"sx-delete",
|
||||
"sx-patch",
|
||||
"sx-trigger",
|
||||
"sx-target",
|
||||
"sx-swap",
|
||||
"sx-swap-oob",
|
||||
"sx-select",
|
||||
"sx-confirm",
|
||||
"sx-push-url",
|
||||
"sx-sync",
|
||||
"sx-encoding",
|
||||
"sx-headers",
|
||||
"sx-include",
|
||||
"sx-vals",
|
||||
"sx-media",
|
||||
"sx-disable",
|
||||
"sx-on", # URL slug for sx-on:*
|
||||
"sx-retry",
|
||||
"data-sx",
|
||||
"data-sx-env",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", ATTRIBUTES)
|
||||
def test_attribute_page_renders(slug: str):
|
||||
"""Each attribute page must render successfully on two consecutive loads."""
|
||||
for attempt in (1, 2):
|
||||
r = _get(f"/reference/attributes/{slug}")
|
||||
assert r.status_code == 200, (
|
||||
f"/reference/attributes/{slug} returned {r.status_code} on attempt {attempt}"
|
||||
)
|
||||
assert len(r.text) > 500, (
|
||||
f"/reference/attributes/{slug} response too short on attempt {attempt}"
|
||||
)
|
||||
# The attribute name (or a prefix of it) should appear somewhere
|
||||
check = slug.rstrip("*").rstrip(":")
|
||||
assert check.lower() in r.text.lower(), (
|
||||
f"/reference/attributes/{slug} does not mention '{check}'"
|
||||
)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Example API endpoints — interactive demos
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestExampleAPIs:
|
||||
"""Test the interactive demo API endpoints."""
|
||||
|
||||
def test_click_to_load(self):
|
||||
r = _get("/examples/api/click")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_form_submission(self):
|
||||
r = _post("/examples/api/form", data={"name": "Alice"})
|
||||
assert r.status_code == 200
|
||||
assert "Alice" in r.text
|
||||
|
||||
def test_polling(self):
|
||||
r = _get("/examples/api/poll")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_delete_row(self):
|
||||
r = _delete("/examples/api/delete/1")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_inline_edit_get(self):
|
||||
r = _get("/examples/api/edit")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_inline_edit_post(self):
|
||||
r = _post("/examples/api/edit", data={"name": "New Name"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_inline_edit_cancel(self):
|
||||
r = _get("/examples/api/edit/cancel")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_oob_swap(self):
|
||||
r = _get("/examples/api/oob")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_lazy_loading(self):
|
||||
r = _get("/examples/api/lazy")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_infinite_scroll(self):
|
||||
r = _get("/examples/api/scroll", params={"page": "1"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_progress_start(self):
|
||||
r = _post("/examples/api/progress/start")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_progress_status(self):
|
||||
r = _get("/examples/api/progress/status")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_active_search(self):
|
||||
r = _get("/examples/api/search", params={"q": "py"})
|
||||
assert r.status_code == 200
|
||||
assert "Python" in r.text
|
||||
|
||||
def test_inline_validation(self):
|
||||
r = _get("/examples/api/validate", params={"email": "test@example.com"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_validation_submit(self):
|
||||
r = _post("/examples/api/validate/submit", data={"email": "test@example.com"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_value_select(self):
|
||||
r = _get("/examples/api/values", params={"category": "Languages"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_reset_on_submit(self):
|
||||
r = _post("/examples/api/reset-submit", data={"message": "hello"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_edit_row_get(self):
|
||||
r = _get("/examples/api/editrow/1")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_edit_row_post(self):
|
||||
r = _post("/examples/api/editrow/1", data={"name": "X", "price": "10", "stock": "5"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_edit_row_cancel(self):
|
||||
r = _get("/examples/api/editrow/1/cancel")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_bulk_update(self):
|
||||
r = _post("/examples/api/bulk", data={"ids": ["1", "2"], "status": "active"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_swap_positions(self):
|
||||
r = _post("/examples/api/swap-log")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_dashboard_filter(self):
|
||||
r = _get("/examples/api/dashboard", params={"region": "all"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_tabs(self):
|
||||
r = _get("/examples/api/tabs/overview")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_animate(self):
|
||||
r = _get("/examples/api/animate")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_dialog_open(self):
|
||||
r = _get("/examples/api/dialog")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_dialog_close(self):
|
||||
r = _get("/examples/api/dialog/close")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_keyboard(self):
|
||||
r = _get("/examples/api/keyboard")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_put_patch_edit(self):
|
||||
r = _get("/examples/api/putpatch/edit-all")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_put_request(self):
|
||||
r = _put("/examples/api/putpatch", data={"name": "X", "email": "x@x.com", "role": "Dev"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_put_patch_cancel(self):
|
||||
r = _get("/examples/api/putpatch/cancel")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_json_encoding(self):
|
||||
r = httpx.post(
|
||||
f"{SX_BASE}/examples/api/json-echo",
|
||||
content='{"key":"val"}',
|
||||
headers={**HEADERS, "Content-Type": "application/json"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_echo_vals(self):
|
||||
r = _get("/examples/api/echo-vals", params={"source": "test"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_echo_headers(self):
|
||||
r = httpx.get(
|
||||
f"{SX_BASE}/examples/api/echo-headers",
|
||||
headers={**HEADERS, "X-Custom": "hello"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_slow_endpoint(self):
|
||||
r = _get("/examples/api/slow")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_slow_search(self):
|
||||
r = _get("/examples/api/slow-search", params={"q": "test"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_flaky_endpoint(self):
|
||||
# May fail 2/3 times — just check it returns *something*
|
||||
r = _get("/examples/api/flaky")
|
||||
assert r.status_code in (200, 503)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
# Reference API endpoints — attribute demos
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestReferenceAPIs:
|
||||
"""Test the reference attribute demo API endpoints."""
|
||||
|
||||
def test_time(self):
|
||||
r = _get("/reference/api/time")
|
||||
assert r.status_code == 200
|
||||
# Should contain a time string (HH:MM:SS pattern)
|
||||
assert re.search(r"\d{2}:\d{2}:\d{2}", r.text), "No time found in response"
|
||||
|
||||
def test_greet(self):
|
||||
r = _post("/reference/api/greet", data={"name": "Bob"})
|
||||
assert r.status_code == 200
|
||||
assert "Bob" in r.text
|
||||
|
||||
def test_status_put(self):
|
||||
r = _put("/reference/api/status", data={"status": "published"})
|
||||
assert r.status_code == 200
|
||||
assert "published" in r.text.lower()
|
||||
|
||||
def test_theme_patch(self):
|
||||
r = _patch("/reference/api/theme", data={"theme": "dark"})
|
||||
assert r.status_code == 200
|
||||
assert "dark" in r.text.lower()
|
||||
|
||||
def test_delete_item(self):
|
||||
r = _delete("/reference/api/item/42")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_trigger_search(self):
|
||||
r = _get("/reference/api/trigger-search", params={"q": "hello"})
|
||||
assert r.status_code == 200
|
||||
assert "hello" in r.text.lower()
|
||||
|
||||
def test_swap_item(self):
|
||||
r = _get("/reference/api/swap-item")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_oob(self):
|
||||
r = _get("/reference/api/oob")
|
||||
assert r.status_code == 200
|
||||
# OOB response should contain sx-swap-oob attribute
|
||||
assert "oob" in r.text.lower()
|
||||
|
||||
def test_select_page(self):
|
||||
r = _get("/reference/api/select-page")
|
||||
assert r.status_code == 200
|
||||
assert "the-content" in r.text
|
||||
|
||||
def test_slow_echo(self):
|
||||
r = _get("/reference/api/slow-echo", params={"q": "sync"})
|
||||
assert r.status_code == 200
|
||||
assert "sync" in r.text.lower()
|
||||
|
||||
def test_upload_name(self):
|
||||
r = _post(
|
||||
"/reference/api/upload-name",
|
||||
files={"file": ("test.txt", b"hello", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "test.txt" in r.text
|
||||
|
||||
def test_echo_headers(self):
|
||||
r = httpx.get(
|
||||
f"{SX_BASE}/reference/api/echo-headers",
|
||||
headers={**HEADERS, "X-Custom-Token": "abc123"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_echo_vals_get(self):
|
||||
r = _get("/reference/api/echo-vals", params={"category": "books"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_echo_vals_post(self):
|
||||
r = _post("/reference/api/echo-vals", data={"source": "demo", "page": "3"})
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_flaky(self):
|
||||
r = _get("/reference/api/flaky")
|
||||
assert r.status_code in (200, 503)
|
||||
Reference in New Issue
Block a user