diff --git a/CLAUDE.md b/CLAUDE.md index bed35dc..5783e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,26 @@ c.approve_node(node_id) c.lock_node(node_id) c.unlock_node(node_id) c.delete_node(node_id) # permanent, moderator only + +# Export (pandoc-powered, 67 output formats) +formats = c.export_formats() +md = c.export_thread(node_id, "markdown") +pdf = c.export_thread(node_id, "pdf") +epub = c.export_namespace("meta.remarkbox.com", "epub") +docx = c.export_node(node_id, "docx") + +# Wiki mode (namespace.wiki=True, any authenticated user can edit root nodes) +c.wiki_edit(node_id, data="Updated content") # creates revision first +revisions = c.get_revisions(node_id) +revision = c.get_revision(revision_id) + +# Themes (auto-generated per namespace, light + dark mode) +css = c.get_theme_css("meta.remarkbox.com") +preview = c.get_theme_preview("meta.remarkbox.com") + +# Multi-syntax input (source_format parameter on create/reply/edit) +c.create_thread(namespace="ns", title="RST", data="Title\n=====\n\nParagraph.", source_format="rst") +c.reply(node_id, data="
HTML reply
", source_format="html") ``` ### Key details @@ -119,6 +139,15 @@ c.delete_node(node_id) # permanent, moderator only | GET | `/api/v1/clients/c` | Download C client (rb.c) | | GET | `/api/v1/admin/namespaces` | List all namespaces (superuser only) | | GET | `/api/v1/admin/recent-nodes?days=7` | Recent nodes network-wide (superuser only) | +| GET | `/api/v1/export/formats` | List available pandoc export formats | +| GET | `/api/v1/export/namespace/{name}.{fmt}` | Export namespace as book | +| GET | `/api/v1/export/threads/{node_id}.{fmt}` | Export thread as document | +| GET | `/api/v1/export/nodes/{node_id}.{fmt}` | Export node subtree (on-demand) | +| GET | `/api/v1/nodes/{id}/revisions` | Revision history for a node | +| POST | `/api/v1/nodes/{id}/wiki-edit` | Wiki-edit a node (creates revision) | +| GET | `/api/v1/revisions/{id}` | Get a specific revision | +| GET | `/api/v1/themes/{namespace}/css` | Auto-generated theme CSS | +| GET | `/api/v1/themes/{namespace}/preview` | Theme palette preview (JSON) | ### Authentication diff --git a/docs/tickets/15.md b/docs/tickets/15.md new file mode 100644 index 0000000..7985f8b --- /dev/null +++ b/docs/tickets/15.md @@ -0,0 +1,120 @@ +# T15: Operation Undigg — Pandoc Export & Wiki Mode + +**Status**: in-progress +**Priority**: high +**Source**: fox directive 2026-03-09 + +## Summary + +Transform remarkbox into a document-first platform. Every namespace is a book, +every root thread is a chapter. Pandoc renders every format it can produce. +Wiki mode lets anyone edit root topics with revision tracking. + +## Architecture + +``` +Namespace (book) → pandoc → all formats (default) + └── Root Node (chapter) → pandoc → all formats (default) + └── Reply (section) → pandoc → on-demand only +``` + +### Data model changes + +1. **Node.source_format** — `Unicode(16)`, default `'markdown'`. Tracks what + syntax the user wrote in (markdown, html, rst, mediawiki, latex, textile, etc). + Pandoc input formats map directly. + +2. **rb_revision** — new table for wiki mode edit history. + - `id` (UUIDType, PK) + - `node_id` (FK → Node) + - `user_id` (FK → User) + - `data` (UnicodeText) — raw source at time of edit + - `source_format` (Unicode(16)) + - `created` (BigInteger) — timestamp + - `revision_number` (Integer) — sequential per node + +3. **Namespace.wiki** — already exists (`Boolean, default=False`), needs + implementation. When True, any authenticated user can edit the root node + of any thread. Each edit creates a revision. + +### Export pipeline + +Pandoc subprocess. Input = markdown/rst/html/whatever `source_format` says. +Output = every format pandoc supports. + +**Default generated (cached on write/edit):** +- Namespace level: one document per namespace containing all root threads +- Root node level: one document per root thread + +**On-demand (generated per request):** +- Any node or subthread at any depth + +**Pandoc output formats** (67 total, subset for default generation): +- markdown, gfm, commonmark, html5, pdf, epub, docx, odt, rst, + mediawiki, latex, man, plain, rtf, asciidoc, textile, org, json + +**URI scheme:** +``` +/api/v1/export/namespace/{name}.{format} — full book +/api/v1/export/threads/{node_id}.{format} — single chapter +/api/v1/export/nodes/{node_id}.{format} — on-demand subthread +``` + +### Content ingestion + +On write (create thread, reply, edit): +1. Accept `source_format` parameter (default: `markdown`) +2. If HTML input, strip to clean body (no head/script/style) +3. Store raw source in `Node.data` with `Node.source_format` +4. Render to HTML via pandoc: `pandoc -f {source_format} -t html5` +5. Sanitize HTML output through existing bleach pipeline +6. Store in `Node.data_html` + +### Wiki mode + +When `Namespace.wiki = True`: +- Any authenticated user can edit root nodes (not just owner/moderator) +- Each edit stores a revision in `rb_revision` before overwriting +- Root node always has the latest version (fast render) +- Revision history accessible via API +- Diff between revisions on-demand + +### Progressive enhancement + +Export menus work without JS (plain links to format URIs). +With JS: dropdown/popover with format picker, async download. + +## Phases + +### Phase 1: Export pipeline (pandoc integration) +- [ ] Tree-to-markdown renderer (walk node tree → single markdown document) +- [ ] Export API endpoints (namespace, thread, node) +- [ ] Pandoc subprocess wrapper +- [ ] Format negotiation (URI suffix or Accept header) + +### Phase 2: Multi-syntax input +- [ ] Add `source_format` column to Node +- [ ] Alembic migration +- [ ] Modify `set_data()` to use pandoc for non-markdown formats +- [ ] Accept `source_format` on create/reply/edit API endpoints +- [ ] HTML stripping for HTML input + +### Phase 3: Wiki mode + revisions +- [ ] Create `rb_revision` model + migration +- [ ] Implement wiki edit permissions in Namespace +- [ ] Store revisions on edit +- [ ] Revision history API endpoint +- [ ] Diff endpoint + +### Phase 4: Auto-generated themes +- [ ] Per-namespace theme generation (light + dark) +- [ ] CSS custom properties for theming +- [ ] Theme preview + +## Notes + +- Pandoc 3.1.3 installed at `/usr/bin/pandoc` +- 43 input formats, 67 output formats +- PDF requires LaTeX (`pdflatex`) or `wkhtmltopdf` — check availability +- `Namespace.wiki` column already exists in schema, just needs implementation +- Export cache invalidation: on any node edit in the tree diff --git a/docs/tickets/index.md b/docs/tickets/index.md index 0c40fb6..2224d3f 100644 --- a/docs/tickets/index.md +++ b/docs/tickets/index.md @@ -19,3 +19,4 @@ Tracked issues from the meta.remarkbox.com and faq.remarkbox.com audit (2026-02- | [T12](12.md) | Reply to API-only CRUD thread confirming done | resolved | low | meta `6db01560` | | [T13](13.md) | Reply to lock/archive thread confirming done | resolved | low | meta `7e9d5864` | | [T14](14.md) | meta/faq SSL outage — missing proxy blocks | resolved | critical | postmortem 2026-02-25 | +| [T15](15.md) | Operation Undigg — Pandoc export & wiki mode | in-progress | high | fox directive 2026-03-09 | diff --git a/remarkbox/api/__init__.py b/remarkbox/api/__init__.py index 5015517..1d67c2d 100644 --- a/remarkbox/api/__init__.py +++ b/remarkbox/api/__init__.py @@ -4,7 +4,11 @@ def includeme(config): config.add_route("api-threads-search", "/api/v1/threads/search") config.add_route("api-thread-detail", "/api/v1/threads/{node_id}") config.add_route("api-thread-replies", "/api/v1/threads/{node_id}/replies") + # Wiki / revision routes (must come before api-node-detail catch-all) + config.add_route("api-node-revisions", "/api/v1/nodes/{node_id}/revisions") + config.add_route("api-node-wiki-edit", "/api/v1/nodes/{node_id}/wiki-edit") config.add_route("api-node-detail", "/api/v1/nodes/{node_id}") + config.add_route("api-revision-detail", "/api/v1/revisions/{revision_id}") config.add_route("api-auth-login", "/api/v1/auth/login") config.add_route("api-auth-verify", "/api/v1/auth/verify") config.add_route("api-user-profile", "/api/v1/user/profile") @@ -13,4 +17,15 @@ def includeme(config): config.add_route("api-webmention", "/api/v1/webmention") config.add_route("api-admin-namespaces", "/api/v1/admin/namespaces") config.add_route("api-admin-recent-nodes", "/api/v1/admin/recent-nodes") + # Export routes — {subpath} captures "name.format" or "node_id.format" + config.add_route("api-export-formats", "/api/v1/export/formats") + config.add_route("api-export-namespace", "/api/v1/export/namespace/{subpath}") + config.add_route("api-export-thread", "/api/v1/export/threads/{subpath}") + config.add_route("api-export-node", "/api/v1/export/nodes/{subpath}") + # Theme routes + config.add_route("api-namespace-theme", "/api/v1/themes/{namespace_name}/css") + config.add_route("api-theme-preview", "/api/v1/themes/{namespace_name}/preview") config.scan("remarkbox.api.views") + config.scan("remarkbox.api.export") + config.scan("remarkbox.api.wiki") + config.scan("remarkbox.api.themes") diff --git a/remarkbox/api/export.py b/remarkbox/api/export.py new file mode 100644 index 0000000..cdf0416 --- /dev/null +++ b/remarkbox/api/export.py @@ -0,0 +1,302 @@ +"""Export API — render threads and namespaces via pandoc. + +URI scheme: + GET /api/v1/export/namespace/{name}.{format} — full book + GET /api/v1/export/threads/{node_id}.{format} — single chapter + GET /api/v1/export/nodes/{node_id}.{format} — on-demand subthread + GET /api/v1/export/formats — list available formats +""" + +import logging +import subprocess + +from pyramid.response import Response +from pyramid.view import view_config + +from remarkbox.models.namespace import get_namespace_by_name +from remarkbox.models.node import ( + get_node_by_id, + get_nodes_who_share_root, +) + +from remarkbox.lib.pandoc import ( + convert, + node_tree_to_markdown, + namespace_to_markdown, + get_available_output_formats, + CONTENT_TYPES, + FILE_EXTENSIONS, + BINARY_FORMATS, +) + +from .views import check_namespace_api_access + +log = logging.getLogger(__name__) + + +def _parse_format_from_subpath(subpath): + """Extract format from the subpath (e.g. 'abc-123.epub' -> ('abc-123', 'epub')).""" + if "." in subpath: + parts = subpath.rsplit(".", 1) + return parts[0], parts[1] + return subpath, "markdown" + + +def _export_response(content, to_format, filename_stem): + """Build a Response for exported content.""" + content_type = CONTENT_TYPES.get(to_format, "application/octet-stream") + ext = FILE_EXTENSIONS.get(to_format, "") + filename = "{}{}".format(filename_stem, ext) + + is_binary = isinstance(content, bytes) + + response = Response( + body=content if is_binary else content.encode("utf-8"), + content_type=content_type, + ) + response.content_disposition = 'attachment; filename="{}"'.format(filename) + return response + + +# --------------------------------------------------------------------------- +# List formats +# --------------------------------------------------------------------------- + + +@view_config( + route_name="api-export-formats", + request_method="GET", + renderer="json", + require_csrf=False, +) +def api_export_formats(request): + """List all available export formats.""" + available = sorted(get_available_output_formats()) + return { + "formats": available, + "count": len(available), + } + + +# --------------------------------------------------------------------------- +# Export thread (single root node = chapter) +# --------------------------------------------------------------------------- + + +@view_config( + route_name="api-export-thread", + request_method="GET", + require_csrf=False, +) +def api_export_thread(request): + """Export a single thread (root node + all replies) in any pandoc format.""" + subpath = request.matchdict["subpath"] + node_id, to_format = _parse_format_from_subpath(subpath) + + available = get_available_output_formats() + if to_format not in available: + request.response.status_code = 400 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Unsupported format: {}".format(to_format), + "available": sorted(available), + } + return request.response + + node = get_node_by_id(request.dbsession, node_id) + if node is None: + request.response.status_code = 404 + request.response.content_type = "application/json" + request.response.json_body = {"error": "Thread not found"} + return request.response + + root = node if node.is_root else node.root + namespace = root.namespace + + denied = check_namespace_api_access(request, namespace) + if denied: + request.response.content_type = "application/json" + request.response.json_body = denied + return request.response + + # Fetch all visible nodes in the thread + nodes = get_nodes_who_share_root( + request.dbsession, root, + exclude_root=True, + visibility_filters={"disabled": False}, + ).all() + + # Render tree to markdown + md = node_tree_to_markdown(root, nodes) + title = root.title or str(root.id) + + if to_format in ("markdown", "gfm", "commonmark"): + # Short-circuit: already markdown, no pandoc needed + return _export_response(md, to_format, root.slug or str(root.id)) + + try: + output = convert(md, from_format="markdown", to_format=to_format, title=title) + except subprocess.CalledProcessError as e: + log.error("Pandoc conversion failed: %s", e.stderr) + request.response.status_code = 500 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Conversion failed", + "detail": e.stderr[:500] if e.stderr else "unknown error", + } + return request.response + + return _export_response(output, to_format, root.slug or str(root.id)) + + +# --------------------------------------------------------------------------- +# Export namespace (full book) +# --------------------------------------------------------------------------- + + +@view_config( + route_name="api-export-namespace", + request_method="GET", + require_csrf=False, +) +def api_export_namespace(request): + """Export an entire namespace as a book in any pandoc format.""" + subpath = request.matchdict["subpath"] + name, to_format = _parse_format_from_subpath(subpath) + + available = get_available_output_formats() + if to_format not in available: + request.response.status_code = 400 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Unsupported format: {}".format(to_format), + "available": sorted(available), + } + return request.response + + namespace = get_namespace_by_name(request.dbsession, name) + if namespace is None: + request.response.status_code = 404 + request.response.content_type = "application/json" + request.response.json_body = {"error": "Namespace not found"} + return request.response + + denied = check_namespace_api_access(request, namespace) + if denied: + request.response.content_type = "application/json" + request.response.json_body = denied + return request.response + + roots = namespace.visible_roots.all() + + def node_fetcher(root): + return get_nodes_who_share_root( + request.dbsession, root, + exclude_root=True, + visibility_filters={"disabled": False}, + ).all() + + md = namespace_to_markdown(namespace, roots, node_fetcher) + title = namespace.description or namespace.name + + if to_format in ("markdown", "gfm", "commonmark"): + return _export_response(md, to_format, name) + + try: + output = convert(md, from_format="markdown", to_format=to_format, title=title) + except subprocess.CalledProcessError as e: + log.error("Pandoc conversion failed: %s", e.stderr) + request.response.status_code = 500 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Conversion failed", + "detail": e.stderr[:500] if e.stderr else "unknown error", + } + return request.response + + return _export_response(output, to_format, name) + + +# --------------------------------------------------------------------------- +# Export node (on-demand subthread at any depth) +# --------------------------------------------------------------------------- + + +@view_config( + route_name="api-export-node", + request_method="GET", + require_csrf=False, +) +def api_export_node(request): + """Export a node and its subtree in any pandoc format (on-demand).""" + subpath = request.matchdict["subpath"] + node_id, to_format = _parse_format_from_subpath(subpath) + + available = get_available_output_formats() + if to_format not in available: + request.response.status_code = 400 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Unsupported format: {}".format(to_format), + "available": sorted(available), + } + return request.response + + node = get_node_by_id(request.dbsession, node_id) + if node is None: + request.response.status_code = 404 + request.response.content_type = "application/json" + request.response.json_body = {"error": "Node not found"} + return request.response + + namespace = node.root.namespace + + denied = check_namespace_api_access(request, namespace) + if denied: + request.response.content_type = "application/json" + request.response.json_body = denied + return request.response + + # For a non-root node, we need to get its subtree. + # Fetch all nodes in the root's tree, then filter to descendants. + from remarkbox.models.node import get_graph_from_nodes, flatten_graph + all_nodes = get_nodes_who_share_root( + request.dbsession, node.root, + visibility_filters={"disabled": False}, + ).all() + + # Include root so the graph is complete + all_with_root = [node.root] + list(all_nodes) if not node.is_root else list(all_nodes) + + graph = get_graph_from_nodes(all_with_root) + + # Get descendant IDs of the target node + if node.id in graph: + descendant_ids = set(flatten_graph(node.id, graph, include_given_node_id=False)) + else: + descendant_ids = set() + + # Filter to just the subtree + subtree_nodes = [n for n in all_nodes if n.id in descendant_ids] + + md = node_tree_to_markdown(node, subtree_nodes, include_root=True) + title = node.title or "Thread {}".format(str(node.id)[:8]) + + if to_format in ("markdown", "gfm", "commonmark"): + slug = node.slug or str(node.id) + return _export_response(md, to_format, slug) + + try: + output = convert(md, from_format="markdown", to_format=to_format, title=title) + except subprocess.CalledProcessError as e: + log.error("Pandoc conversion failed: %s", e.stderr) + request.response.status_code = 500 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Conversion failed", + "detail": e.stderr[:500] if e.stderr else "unknown error", + } + return request.response + + slug = node.slug or str(node.id) + return _export_response(output, to_format, slug) diff --git a/remarkbox/api/remarkbox_client.py b/remarkbox/api/remarkbox_client.py index c656ecb..9d0c9a0 100644 --- a/remarkbox/api/remarkbox_client.py +++ b/remarkbox/api/remarkbox_client.py @@ -225,15 +225,18 @@ class RemarkboxClient: path += "?" + urllib.parse.urlencode(params) return self._request("GET", path) - def create_thread(self, namespace, title, data, anonymous_name=None, email=None): + def create_thread(self, namespace, title, data, anonymous_name=None, email=None, + source_format=None): """Create a new thread. Args: namespace: Target namespace name title: Thread title - data: Markdown content (max 500000 chars) + data: Content (max 500000 chars) anonymous_name: Name for anonymous posting (optional) email: Email to associate with post (optional) + source_format: Input format — markdown (default), html, rst, + mediawiki, latex, textile, org, etc. (any pandoc input format) Returns: dict with keys: node, verified @@ -243,18 +246,21 @@ class RemarkboxClient: body["anonymous_name"] = anonymous_name if email: body["email"] = email + if source_format: + body["source_format"] = source_format return self._request("POST", "/api/v1/threads", body) # ----- Replies ----- - def reply(self, node_id, data, anonymous_name=None, email=None): + def reply(self, node_id, data, anonymous_name=None, email=None, source_format=None): """Reply to a thread or another reply. Args: node_id: UUID of the parent node (thread or reply) - data: Markdown content (max 500000 chars) + data: Content (max 500000 chars) anonymous_name: Name for anonymous posting (optional) email: Email to associate with post (optional) + source_format: Input format (default: markdown) Returns: dict with keys: node, verified @@ -264,6 +270,8 @@ class RemarkboxClient: body["anonymous_name"] = anonymous_name if email: body["email"] = email + if source_format: + body["source_format"] = source_format return self._request("POST", "/api/v1/threads/{}/replies".format(node_id), body) # ----- Nodes ----- @@ -279,13 +287,14 @@ class RemarkboxClient: """ return self._request("GET", "/api/v1/nodes/{}".format(node_id)) - def edit_node(self, node_id, data=None, title=None): + def edit_node(self, node_id, data=None, title=None, source_format=None): """Edit a node (requires authentication). Args: node_id: UUID of the node to edit - data: New markdown content (optional) + data: New content (optional) title: New title, only for root nodes (optional) + source_format: Input format (default: markdown) Returns: dict with key: node @@ -295,6 +304,8 @@ class RemarkboxClient: body["data"] = data if title is not None: body["title"] = title + if source_format is not None: + body["source_format"] = source_format if not body: raise ValueError("data or title is required") return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), body) @@ -431,6 +442,145 @@ class RemarkboxClient: """ return self._request("PATCH", "/api/v1/user/profile", {"name": name}) + # ----- Export ----- + + def export_formats(self): + """List all available export formats. + + Returns: + dict with keys: formats (list of strings), count + """ + return self._request("GET", "/api/v1/export/formats") + + def export_thread(self, node_id, fmt="markdown"): + """Export a thread in the specified format. + + Args: + node_id: UUID of the root thread node + fmt: Pandoc output format (e.g. markdown, html5, pdf, epub, docx) + + Returns: + bytes (binary formats) or str (text formats) + """ + return self._raw_request( + "GET", "/api/v1/export/threads/{}.{}".format(node_id, fmt) + ) + + def export_namespace(self, namespace, fmt="markdown"): + """Export an entire namespace as a book in the specified format. + + Args: + namespace: Namespace name (e.g. "meta.remarkbox.com") + fmt: Pandoc output format + + Returns: + bytes (binary formats) or str (text formats) + """ + return self._raw_request( + "GET", "/api/v1/export/namespace/{}.{}".format(namespace, fmt) + ) + + def export_node(self, node_id, fmt="markdown"): + """Export a node and its subtree in the specified format (on-demand). + + Args: + node_id: UUID of the node + fmt: Pandoc output format + + Returns: + bytes (binary formats) or str (text formats) + """ + return self._raw_request( + "GET", "/api/v1/export/nodes/{}.{}".format(node_id, fmt) + ) + + def _raw_request(self, method, path): + """Make an HTTP request and return raw response body.""" + url = self.url + path + req = urllib.request.Request(url, method=method) + try: + resp = self._opener.open(req) + content_type = resp.headers.get("Content-Type", "") + body = resp.read() + if "text/" in content_type or "json" in content_type or "xml" in content_type: + return body.decode("utf-8") + return body + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8") + try: + body = json.loads(raw) + except Exception: + body = {"error": raw} + raise RemarkboxError(e.code, body) + + # ----- Wiki / Revisions ----- + + def wiki_edit(self, node_id, data, source_format=None): + """Wiki-edit a node (creates revision, any authenticated user if wiki mode). + + Args: + node_id: UUID of the node to edit + data: New content + source_format: Input format (default: markdown) + + Returns: + dict with key: node + """ + body = {"data": data} + if source_format: + body["source_format"] = source_format + return self._request("POST", "/api/v1/nodes/{}/wiki-edit".format(node_id), body) + + def get_revisions(self, node_id): + """Get revision history for a node. + + Args: + node_id: UUID of the node + + Returns: + dict with keys: node_id, revisions, count + """ + return self._request("GET", "/api/v1/nodes/{}/revisions".format(node_id)) + + def get_revision(self, revision_id): + """Get a specific revision by ID. + + Args: + revision_id: UUID of the revision + + Returns: + dict with key: revision + """ + return self._request("GET", "/api/v1/revisions/{}".format(revision_id)) + + # ----- Themes ----- + + def get_theme_css(self, namespace): + """Get the auto-generated theme CSS for a namespace. + + Args: + namespace: Namespace name + + Returns: + CSS string + """ + return self._raw_request( + "GET", "/api/v1/themes/{}/css".format(namespace) + ) + + def get_theme_preview(self, namespace): + """Get theme palette preview for a namespace. + + Args: + namespace: Namespace name + + Returns: + dict with keys: namespace, palette, css_url + """ + return self._request( + "GET", "/api/v1/themes/{}/preview".format(namespace) + ) + # ----- Admin (superuser only) ----- def admin_list_namespaces(self): @@ -477,11 +627,15 @@ Commands: deleteParagraph.
", "html", "markdown") + self.assertIn("Title", result) + self.assertIn("Paragraph.", result) + + def test_rst_to_html(self): + rst = "Title\n=====\n\nA paragraph." + result = convert(rst, "rst", "html5") + self.assertIn("Title", result) + self.assertIn("paragraph", result) + + def test_mediawiki_to_html(self): + wiki = "== Section ==\n\n'''bold''' text" + result = convert(wiki, "mediawiki", "html5") + self.assertIn("Section", result) + + def test_latex_to_html(self): + latex = r"\section{Hello}\textbf{bold}" + result = convert(latex, "latex", "html5") + self.assertIn("Hello", result) + + def test_markdown_to_json(self): + result = convert("# Test", "markdown", "json") + self.assertIn('"pandoc-api-version"', result) + + def test_markdown_to_epub_returns_bytes(self): + result = convert("# Test\n\nContent.", "markdown", "epub", title="Test") + self.assertIsInstance(result, bytes) + self.assertGreater(len(result), 100) + + def test_markdown_to_pdf_returns_bytes(self): + result = convert("# Test\n\nContent.", "markdown", "pdf", title="Test") + self.assertIsInstance(result, bytes) + # PDF starts with %PDF + self.assertTrue(result[:4] == b"%PDF") + + def test_markdown_to_docx_returns_bytes(self): + result = convert("# Test\n\nContent.", "markdown", "docx", title="Test") + self.assertIsInstance(result, bytes) + # DOCX is a ZIP file (starts with PK) + self.assertTrue(result[:2] == b"PK") + + def test_title_metadata(self): + result = convert("Content.", "markdown", "html5", title="My Title") + self.assertIn("My Title", result) + + def test_empty_input(self): + result = convert("", "markdown", "html5") + self.assertIsInstance(result, str) + + def test_invalid_format_raises(self): + with self.assertRaises(subprocess.CalledProcessError): + convert("test", "markdown", "not_a_real_format_xyz") + + +class TestAvailableFormats(unittest.TestCase): + """Test format discovery.""" + + def test_output_formats_returns_frozenset(self): + formats = get_available_output_formats() + self.assertIsInstance(formats, frozenset) + self.assertGreater(len(formats), 50) + self.assertIn("html5", formats) + self.assertIn("markdown", formats) + self.assertIn("pdf", formats) + self.assertIn("epub", formats) + + def test_input_formats_returns_frozenset(self): + formats = get_available_input_formats() + self.assertIsInstance(formats, frozenset) + self.assertGreater(len(formats), 30) + self.assertIn("html", formats) + self.assertIn("markdown", formats) + self.assertIn("rst", formats) + + def test_content_types_has_common_formats(self): + self.assertIn("html5", CONTENT_TYPES) + self.assertIn("pdf", CONTENT_TYPES) + self.assertIn("epub", CONTENT_TYPES) + self.assertIn("docx", CONTENT_TYPES) + self.assertIn("markdown", CONTENT_TYPES) + self.assertEqual(CONTENT_TYPES["pdf"], "application/pdf") + + def test_file_extensions_has_common_formats(self): + self.assertEqual(FILE_EXTENSIONS["markdown"], ".md") + self.assertEqual(FILE_EXTENSIONS["html5"], ".html") + self.assertEqual(FILE_EXTENSIONS["pdf"], ".pdf") + self.assertEqual(FILE_EXTENSIONS["epub"], ".epub") + self.assertEqual(FILE_EXTENSIONS["docx"], ".docx") + + def test_binary_formats(self): + self.assertIn("pdf", BINARY_FORMATS) + self.assertIn("docx", BINARY_FORMATS) + self.assertIn("epub", BINARY_FORMATS) + self.assertNotIn("html5", BINARY_FORMATS) + self.assertNotIn("markdown", BINARY_FORMATS) + + +class MockNode: + """Minimal mock of Node for tree rendering tests.""" + + def __init__(self, id, title=None, data=None, parent_id=None, + created=0, disabled=False, user=None, user_surrogate=None, + created_date="2026-01-01"): + self.id = id + self.title = title + self.data = data + self.parent_id = parent_id + self.created = created + self.disabled = disabled + self.user = user + self.user_surrogate = user_surrogate + self.created_date = created_date + self.is_root = parent_id is None + + +class MockUser: + def __init__(self, name): + self.name = name + + +class MockNamespace: + def __init__(self, name, description=None): + self.name = name + self.description = description + + +class TestNodeTreeToMarkdown(unittest.TestCase): + """Test tree-to-markdown rendering.""" + + def test_single_root_node(self): + root = MockNode(1, title="Thread Title", data="Root content.") + md = node_tree_to_markdown(root, []) + self.assertIn("# Thread Title", md) + self.assertIn("Root content.", md) + + def test_root_with_children(self): + root = MockNode(1, title="Thread", data="Root.") + child = MockNode(2, data="Reply text.", parent_id=1, created=1, + user=MockUser("alice")) + md = node_tree_to_markdown(root, [child]) + self.assertIn("# Thread", md) + self.assertIn("Root.", md) + self.assertIn("alice", md) + self.assertIn("Reply text.", md) + + def test_disabled_children_excluded(self): + root = MockNode(1, title="Thread", data="Root.") + child = MockNode(2, data="Visible.", parent_id=1, created=1, + user=MockUser("bob")) + disabled = MockNode(3, data="Hidden.", parent_id=1, created=2, + disabled=True, user=MockUser("spam")) + md = node_tree_to_markdown(root, [child, disabled]) + self.assertIn("Visible.", md) + self.assertNotIn("Hidden.", md) + + def test_nested_children(self): + root = MockNode(1, title="Thread", data="Root.") + child = MockNode(2, data="Level 1.", parent_id=1, created=1, + user=MockUser("alice")) + grandchild = MockNode(3, data="Level 2.", parent_id=2, created=2, + user=MockUser("bob")) + md = node_tree_to_markdown(root, [child, grandchild]) + self.assertIn("Level 1.", md) + self.assertIn("Level 2.", md) + # Grandchild should have deeper heading + self.assertIn("### bob", md) + + def test_exclude_root(self): + root = MockNode(1, title="Hidden Title", data="Hidden root.") + md = node_tree_to_markdown(root, [], include_root=False) + self.assertNotIn("Hidden Title", md) + self.assertNotIn("Hidden root.", md) + + def test_anonymous_author(self): + root = MockNode(1, title="Thread") + child = MockNode(2, data="Anon post.", parent_id=1, created=1) + md = node_tree_to_markdown(root, [child]) + self.assertIn("Anonymous", md) + + def test_surrogate_author(self): + root = MockNode(1, title="Thread") + child = MockNode(2, data="Bot post.", parent_id=1, created=1, + user_surrogate=MockUser("MyBot")) + md = node_tree_to_markdown(root, [child]) + self.assertIn("MyBot", md) + + +class TestNamespaceToMarkdown(unittest.TestCase): + """Test namespace (book) rendering.""" + + def test_empty_namespace(self): + ns = MockNamespace("test.com", "Test Forum") + md = namespace_to_markdown(ns, [], lambda r: []) + self.assertIn("# Test Forum", md) + + def test_namespace_with_threads(self): + ns = MockNamespace("test.com", "My Forum") + root1 = MockNode(1, title="First Thread", data="Content 1.") + root2 = MockNode(2, title="Second Thread", data="Content 2.") + + def fetcher(root): + return [] + + md = namespace_to_markdown(ns, [root1, root2], fetcher) + self.assertIn("# My Forum", md) + self.assertIn("## First Thread", md) + self.assertIn("## Second Thread", md) + self.assertIn("Content 1.", md) + self.assertIn("Content 2.", md) + + def test_namespace_name_as_fallback_title(self): + ns = MockNamespace("test.com") + md = namespace_to_markdown(ns, [], lambda r: []) + self.assertIn("# test.com", md) + + def test_disabled_roots_excluded(self): + ns = MockNamespace("test.com") + root = MockNode(1, title="Visible", data="Yes.", disabled=False) + hidden = MockNode(2, title="Hidden", data="No.", disabled=True) + md = namespace_to_markdown(ns, [root, hidden], lambda r: []) + self.assertIn("Visible", md) + self.assertNotIn("Hidden", md) + + +class TestGetAuthorName(unittest.TestCase): + """Test _get_author_name helper.""" + + def test_user(self): + node = MockNode(1, user=MockUser("alice")) + self.assertEqual(_get_author_name(node), "alice") + + def test_surrogate(self): + node = MockNode(1, user_surrogate=MockUser("BotName")) + self.assertEqual(_get_author_name(node), "BotName") + + def test_anonymous(self): + node = MockNode(1) + self.assertEqual(_get_author_name(node), "Anonymous") diff --git a/remarkbox/tests/test_revision.py b/remarkbox/tests/test_revision.py new file mode 100644 index 0000000..0f73678 --- /dev/null +++ b/remarkbox/tests/test_revision.py @@ -0,0 +1,93 @@ +"""Unit tests for remarkbox.models.revision — wiki mode revision tracking.""" + +import unittest +from unittest import mock + +from remarkbox.models.revision import Revision +from remarkbox.models.node import Node +from remarkbox.models.namespace import Namespace + + +class TestRevisionModel(unittest.TestCase): + """Unit tests for the Revision model.""" + + def test_init_sets_uuid(self): + rev = Revision() + self.assertIsNotNone(rev.id) + + def test_init_sets_timestamp(self): + rev = Revision() + self.assertIsNotNone(rev.created) + self.assertGreater(rev.created, 0) + + def test_init_with_data(self): + rev = Revision( + data="Some content", + source_format="markdown", + revision_number=3, + ) + self.assertEqual(rev.data, "Some content") + self.assertEqual(rev.source_format, "markdown") + self.assertEqual(rev.revision_number, 3) + + def test_default_source_format(self): + rev = Revision() + self.assertEqual(rev.source_format, "markdown") + + def test_default_revision_number(self): + rev = Revision() + self.assertEqual(rev.revision_number, 1) + + +class TestNamespaceCanWikiEdit(unittest.TestCase): + """Unit tests for Namespace.can_wiki_edit().""" + + def setUp(self): + self.ns = Namespace("test-wiki.com") + self.ns.subscription_type = "production" + + def test_unauthenticated_user_denied(self): + user = mock.Mock() + user.authenticated = False + node = mock.Mock() + self.assertFalse(self.ns.can_wiki_edit(node, user)) + + def test_none_user_denied(self): + node = mock.Mock() + self.assertFalse(self.ns.can_wiki_edit(node, None)) + + def test_owner_can_always_edit(self): + user = mock.Mock() + user.authenticated = True + node = mock.Mock() + node.is_root = False + # can_alter_node returns True for owners + with mock.patch.object(self.ns, 'can_alter_node', return_value=True): + self.assertTrue(self.ns.can_wiki_edit(node, user)) + + def test_wiki_mode_allows_root_edit(self): + self.ns.wiki = True + user = mock.Mock() + user.authenticated = True + node = mock.Mock() + node.is_root = True + with mock.patch.object(self.ns, 'can_alter_node', return_value=False): + self.assertTrue(self.ns.can_wiki_edit(node, user)) + + def test_wiki_mode_denies_non_root_edit(self): + self.ns.wiki = True + user = mock.Mock() + user.authenticated = True + node = mock.Mock() + node.is_root = False + with mock.patch.object(self.ns, 'can_alter_node', return_value=False): + self.assertFalse(self.ns.can_wiki_edit(node, user)) + + def test_non_wiki_mode_denies_non_owner(self): + self.ns.wiki = False + user = mock.Mock() + user.authenticated = True + node = mock.Mock() + node.is_root = True + with mock.patch.object(self.ns, 'can_alter_node', return_value=False): + self.assertFalse(self.ns.can_wiki_edit(node, user)) diff --git a/remarkbox/tests/test_theme_generator.py b/remarkbox/tests/test_theme_generator.py new file mode 100644 index 0000000..826755a --- /dev/null +++ b/remarkbox/tests/test_theme_generator.py @@ -0,0 +1,113 @@ +"""Unit tests for remarkbox.lib.theme_generator.""" + +import unittest + +from remarkbox.lib.theme_generator import ( + generate_theme_css, + _name_to_hue, + _name_to_seed, +) + + +class TestNameToHue(unittest.TestCase): + """Test deterministic hue generation.""" + + def test_returns_int_in_range(self): + hue = _name_to_hue("test.com") + self.assertIsInstance(hue, int) + self.assertGreaterEqual(hue, 0) + self.assertLess(hue, 360) + + def test_deterministic(self): + h1 = _name_to_hue("meta.remarkbox.com") + h2 = _name_to_hue("meta.remarkbox.com") + self.assertEqual(h1, h2) + + def test_different_names_different_hues(self): + h1 = _name_to_hue("meta.remarkbox.com") + h2 = _name_to_hue("faq.remarkbox.com") + # Could theoretically collide but extremely unlikely + self.assertNotEqual(h1, h2) + + +class TestNameToSeed(unittest.TestCase): + """Test seed generation.""" + + def test_returns_tuple_of_ints(self): + seed = _name_to_seed("test.com") + self.assertIsInstance(seed, tuple) + self.assertEqual(len(seed), 8) + for v in seed: + self.assertIsInstance(v, int) + + def test_deterministic(self): + s1 = _name_to_seed("test.com") + s2 = _name_to_seed("test.com") + self.assertEqual(s1, s2) + + +class TestGenerateThemeCSS(unittest.TestCase): + """Test CSS theme generation.""" + + def test_returns_string(self): + css = generate_theme_css("test.com") + self.assertIsInstance(css, str) + self.assertGreater(len(css), 500) + + def test_deterministic(self): + css1 = generate_theme_css("meta.remarkbox.com") + css2 = generate_theme_css("meta.remarkbox.com") + self.assertEqual(css1, css2) + + def test_different_namespaces_different_css(self): + css1 = generate_theme_css("meta.remarkbox.com") + css2 = generate_theme_css("faq.remarkbox.com") + self.assertNotEqual(css1, css2) + + def test_contains_css_custom_properties(self): + css = generate_theme_css("test.com") + self.assertIn("--rb-bg:", css) + self.assertIn("--rb-text:", css) + self.assertIn("--rb-link:", css) + self.assertIn("--rb-accent:", css) + self.assertIn("--rb-border:", css) + + def test_contains_light_mode(self): + css = generate_theme_css("test.com") + self.assertIn(":root", css) + self.assertIn(".theme-light", css) + + def test_contains_dark_mode(self): + css = generate_theme_css("test.com") + self.assertIn("prefers-color-scheme: dark", css) + self.assertIn(".theme-dark", css) + + def test_contains_namespace_name_comment(self): + css = generate_theme_css("my-forum.example.org") + self.assertIn("my-forum.example.org", css) + + def test_contains_element_styles(self): + css = generate_theme_css("test.com") + self.assertIn(".node", css) + self.assertIn("blockquote", css) + self.assertIn(".rb-submit", css) + self.assertIn("textarea", css) + + def test_uses_hsl_colors(self): + css = generate_theme_css("test.com") + self.assertIn("hsl(", css) + + def test_various_namespaces_all_valid(self): + """Ensure no crashes for various namespace name patterns.""" + names = [ + "a.com", + "very-long-domain-name-that-goes-on.example.com", + "unicode-test-日本語.com", + "123.456.789", + "", + "single", + ] + for name in names: + css = generate_theme_css(name) + self.assertIsInstance(css, str) + self.assertGreater(len(css), 100) diff --git a/remarkbox/tests/test_undigg.py b/remarkbox/tests/test_undigg.py new file mode 100644 index 0000000..73612ef --- /dev/null +++ b/remarkbox/tests/test_undigg.py @@ -0,0 +1,762 @@ +"""Integration and functional tests for Operation Undigg. + +Tests the export, multi-syntax, wiki mode, and theme APIs end-to-end +via webtest against the full Pyramid app. +""" + +import transaction +import unittest +import webtest + +from remarkbox.models import ( + Node, + get_tm_session, + get_or_create_user_by_email, + get_or_create_namespace, + UserSurrogate, +) + +from remarkbox.models.meta import Base +from remarkbox.models.revision import Revision + +from pyramid.paster import get_appsettings + + +class UndiggFunctionalTests(unittest.TestCase): + """Base class for Undigg functional tests.""" + + @classmethod + def setUpClass(cls): + from remarkbox import main + + cls.settings = get_appsettings("test.ini") + cls.app = main({}, **cls.settings) + cls.testapp = webtest.TestApp(cls.app) + + cls.session_factory = cls.app.registry["dbsession_factory"] + cls.engine = cls.session_factory.kw["bind"] + Base.metadata.create_all(bind=cls.engine) + + cls.tm = transaction.manager + cls.dbsession = get_tm_session(cls.session_factory, cls.tm) + + @classmethod + def tearDownClass(cls): + cls.dbsession.close() + Base.metadata.drop_all(bind=cls.engine) + + def tearDown(self): + self.testapp.get("/log-out") + + +# --------------------------------------------------------------------------- +# Export API +# --------------------------------------------------------------------------- + + +class TestExportFormats(UndiggFunctionalTests): + """Test GET /api/v1/export/formats.""" + + def test_list_formats(self): + res = self.testapp.get("/api/v1/export/formats") + self.assertEqual(res.status_int, 200) + body = res.json + self.assertIn("formats", body) + self.assertIn("count", body) + self.assertGreater(body["count"], 50) + self.assertIn("html5", body["formats"]) + self.assertIn("pdf", body["formats"]) + self.assertIn("markdown", body["formats"]) + + +class TestExportThread(UndiggFunctionalTests): + """Test GET /api/v1/export/threads/{id}.{format}.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "export-test.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestExportThread, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def _create_thread(self, title="Test Thread", data="Thread content."): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": title, + "data": data, + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + return res.json["node"]["id"] + + def test_export_thread_markdown(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.markdown".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn("text/markdown", res.content_type) + self.assertIn(b"Test Thread", res.body) + self.assertIn(b"Thread content.", res.body) + + def test_export_thread_html(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.html5".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn("text/html", res.content_type) + self.assertIn(b"Test Thread", res.body) + + def test_export_thread_rst(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.rst".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn(b"Test Thread", res.body) + + def test_export_thread_pdf(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.pdf".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/pdf") + self.assertTrue(res.body[:4] == b"%PDF") + + def test_export_thread_epub(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.epub".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/epub+zip") + self.assertGreater(len(res.body), 100) + + def test_export_thread_docx(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.docx".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + # DOCX is a ZIP + self.assertTrue(res.body[:2] == b"PK") + + def test_export_thread_not_found(self): + res = self.testapp.get( + "/api/v1/export/threads/00000000-0000-0000-0000-000000000000.markdown", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_export_thread_unsupported_format(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.not_a_format".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 400) + + def test_export_thread_with_replies(self): + """Thread with replies includes reply content in export.""" + node_id = self._create_thread() + self.testapp.post_json( + "/api/v1/threads/{}/replies".format(node_id), + {"data": "A reply from alice.", "anonymous_name": "alice"}, + expect_errors=True, + ) + res = self.testapp.get( + "/api/v1/export/threads/{}.markdown".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + body = res.body.decode("utf-8") + self.assertIn("A reply from alice.", body) + self.assertIn("alice", body) + + def test_export_content_disposition(self): + node_id = self._create_thread(title="My Export Test") + res = self.testapp.get( + "/api/v1/export/threads/{}.markdown".format(node_id), + expect_errors=True, + ) + self.assertIn("attachment", res.headers.get("Content-Disposition", "")) + + +class TestExportNamespace(UndiggFunctionalTests): + """Test GET /api/v1/export/namespace/{name}.{format}.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "ns-export.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestExportNamespace, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_export_namespace_markdown(self): + # Create a thread + self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Chapter One", + "data": "First chapter content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + res = self.testapp.get( + "/api/v1/export/namespace/{}.markdown".format(self.namespace_name), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + body = res.body.decode("utf-8") + self.assertIn("Chapter One", body) + self.assertIn("First chapter content.", body) + + def test_export_namespace_html(self): + self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "HTML Chapter", + "data": "Content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + res = self.testapp.get( + "/api/v1/export/namespace/{}.html5".format(self.namespace_name), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn("text/html", res.content_type) + + def test_export_namespace_not_found(self): + res = self.testapp.get( + "/api/v1/export/namespace/nonexistent.example.com.markdown", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_export_empty_namespace(self): + ns = get_or_create_namespace(self.dbsession, "empty-ns.example.com") + self.dbsession.add(ns) + self.dbsession.flush() + self.tm.commit() + + res = self.testapp.get( + "/api/v1/export/namespace/empty-ns.example.com.markdown", + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + + +class TestExportNode(UndiggFunctionalTests): + """Test GET /api/v1/export/nodes/{id}.{format} (on-demand subthread).""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "node-export.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestExportNode, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_export_node_subtree(self): + # Create thread with a reply + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Thread", + "data": "Root content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + root_id = res.json["node"]["id"] + + self.testapp.post_json( + "/api/v1/threads/{}/replies".format(root_id), + {"data": "Reply content.", "anonymous_name": "Bot"}, + expect_errors=True, + ) + + # Export the reply's subtree + export_res = self.testapp.get( + "/api/v1/export/nodes/{}.markdown".format(root_id), + expect_errors=True, + ) + self.assertEqual(export_res.status_int, 200) + body = export_res.body.decode("utf-8") + self.assertIn("Root content.", body) + + def test_export_node_not_found(self): + res = self.testapp.get( + "/api/v1/export/nodes/00000000-0000-0000-0000-000000000000.markdown", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + +# --------------------------------------------------------------------------- +# Multi-Syntax Input +# --------------------------------------------------------------------------- + + +class TestMultiSyntaxInput(UndiggFunctionalTests): + """Test source_format parameter on create/reply/edit.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "syntax-test.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestMultiSyntaxInput, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_create_thread_default_markdown(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Markdown Thread", + "data": "**bold** text", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + self.assertEqual(res.status_int, 201) + self.assertEqual(res.json["node"]["source_format"], "markdown") + self.assertIn("bold", res.json["node"]["data_html"]) + + def test_create_thread_rst(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "RST Thread", + "data": "A paragraph with **bold** text.", + "anonymous_name": "Bot", + "source_format": "rst", + }, + expect_errors=True, + ) + self.assertEqual(res.status_int, 201) + self.assertEqual(res.json["node"]["source_format"], "rst") + self.assertIn("bold", res.json["node"]["data_html"]) + + def test_create_thread_html_stripped(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "HTML Thread", + "data": "Paragraph.
", + "anonymous_name": "Bot", + "source_format": "html", + }, + expect_errors=True, + ) + self.assertEqual(res.status_int, 201) + # HTML input gets normalized to markdown + self.assertEqual(res.json["node"]["source_format"], "markdown") + self.assertIn("Paragraph.", res.json["node"]["data"]) + + def test_reply_with_source_format(self): + # Create thread + create_res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Thread", + "data": "Content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + node_id = create_res.json["node"]["id"] + + # Reply with RST + reply_res = self.testapp.post_json( + "/api/v1/threads/{}/replies".format(node_id), + { + "data": "**RST bold** reply.", + "anonymous_name": "Bot", + "source_format": "rst", + }, + expect_errors=True, + ) + self.assertEqual(reply_res.status_int, 201) + self.assertEqual(reply_res.json["node"]["source_format"], "rst") + + def test_serializer_includes_source_format(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Test", + "data": "Content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + self.assertIn("source_format", res.json["node"]) + + +class TestMultiSyntaxMediawiki(UndiggFunctionalTests): + """Test mediawiki input format.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "wiki-syntax.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestMultiSyntaxMediawiki, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_create_thread_mediawiki(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Wiki Thread", + "data": "== Section ==\n\n'''bold''' text", + "anonymous_name": "Bot", + "source_format": "mediawiki", + }, + expect_errors=True, + ) + self.assertEqual(res.status_int, 201) + self.assertEqual(res.json["node"]["source_format"], "mediawiki") + # The HTML should contain the rendered content + self.assertIn("Section", res.json["node"]["data_html"]) + + +# --------------------------------------------------------------------------- +# Wiki Mode & Revisions +# --------------------------------------------------------------------------- + + +class TestWikiModeAndRevisions(UndiggFunctionalTests): + """Test wiki-edit and revision history endpoints.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "wiki-test.example.com") + ns.allow_anonymous = True + # Enable wiki mode + ns.subscription_type = "production" + ns.wiki = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestWikiModeAndRevisions, self).tearDown() + # Clean up revisions first (FK constraint) + self.dbsession.query(Revision).filter( + Revision.node_id.in_( + self.dbsession.query(Node.id).filter( + Node.namespace_id == self.namespace_id + ) + ) + ).delete(synchronize_session=False) + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_wiki_edit_requires_auth(self): + # Create a thread + create_res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Wiki Thread", + "data": "Original.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + node_id = create_res.json["node"]["id"] + + # Try wiki-edit without auth + res = self.testapp.post_json( + "/api/v1/nodes/{}/wiki-edit".format(node_id), + {"data": "Updated."}, + expect_errors=True, + ) + self.assertEqual(res.status_int, 401) + + def test_revisions_empty_initially(self): + create_res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Wiki Thread", + "data": "Original.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + node_id = create_res.json["node"]["id"] + + res = self.testapp.get( + "/api/v1/nodes/{}/revisions".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.json["count"], 0) + self.assertEqual(res.json["revisions"], []) + + def test_revisions_not_found(self): + res = self.testapp.get( + "/api/v1/nodes/00000000-0000-0000-0000-000000000000/revisions", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_wiki_edit_missing_data(self): + create_res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Wiki Thread", + "data": "Original.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + node_id = create_res.json["node"]["id"] + + # Need to be authenticated to even get past the auth check + # Since we can't easily authenticate in these tests, just verify + # the 401 response for unauthenticated users + res = self.testapp.post_json( + "/api/v1/nodes/{}/wiki-edit".format(node_id), + {}, + expect_errors=True, + ) + self.assertEqual(res.status_int, 401) + + def test_wiki_edit_node_not_found(self): + res = self.testapp.post_json( + "/api/v1/nodes/00000000-0000-0000-0000-000000000000/wiki-edit", + {"data": "Updated."}, + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_revision_detail_not_found(self): + res = self.testapp.get( + "/api/v1/revisions/00000000-0000-0000-0000-000000000000", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + +# --------------------------------------------------------------------------- +# Theme API +# --------------------------------------------------------------------------- + + +class TestThemeAPI(UndiggFunctionalTests): + """Test theme generation endpoints.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "theme-test.example.com") + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.tm.commit() + + def test_get_theme_css(self): + res = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn("text/css", res.content_type) + body = res.body.decode("utf-8") + self.assertIn("--rb-bg:", body) + self.assertIn("--rb-text:", body) + self.assertIn("prefers-color-scheme: dark", body) + + def test_get_theme_css_deterministic(self): + res1 = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name)) + res2 = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name)) + self.assertEqual(res1.body, res2.body) + + def test_get_theme_css_cached(self): + res = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name)) + self.assertIn("max-age", res.headers.get("Cache-Control", "")) + + def test_get_theme_css_not_found(self): + res = self.testapp.get( + "/api/v1/themes/nonexistent-ns-xyz.example.com/css", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_get_theme_preview(self): + res = self.testapp.get( + "/api/v1/themes/{}/preview".format(self.namespace_name), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + body = res.json + self.assertIn("palette", body) + self.assertIn("primary_hue", body["palette"]) + self.assertIn("secondary_hue", body["palette"]) + self.assertIn("accent_hue", body["palette"]) + self.assertIn("css_url", body) + + def test_get_theme_preview_not_found(self): + res = self.testapp.get( + "/api/v1/themes/nonexistent-ns-xyz.example.com/preview", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_different_namespaces_different_themes(self): + ns2 = get_or_create_namespace(self.dbsession, "theme-test-2.example.com") + self.dbsession.add(ns2) + self.dbsession.flush() + self.tm.commit() + + res1 = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name)) + res2 = self.testapp.get( + "/api/v1/themes/theme-test-2.example.com/css") + self.assertNotEqual(res1.body, res2.body)