From cdba720a1cb713d21c6fbb59d6eca9ba36055971 Mon Sep 17 00:00:00 2001 From: Chelsea Date: Fri, 3 Jul 2026 01:30:13 -0500 Subject: [PATCH 1/2] Phase 0: stop-the-bleeding bugfixes - Defer polling_service.start() and FilterEngine init to a one-shot before_request hook so importing app.py no longer spawns a scheduler thread (also fixes migrate_*.py import side effects). - Fix migrate_bookmarks.py: init_db returns None, so use the shared `db` instead of assigning its None return. - Enforce MIN_PASSWORD_LENGTH (8) in the password-reset route for consistency with signup (was hardcoded 6). - post_detail.html: replace undefined moment(...).fromNow() (always "Recently") with a new timeago Jinja filter + data-timestamp attrs that also drive the existing JS updater; make nl2br escape-then-Markup and drop | safe from comment/post content to close the XSS hole. - filter_pipeline: when AI is disabled but a filterset requires it, pass posts through with status=FAILED + explicit error instead of silently degrading to no_filter. No source-file mojibake found; content-encoding ingest is a Phase 3 concern. Co-Authored-By: Claude --- app.py | 78 ++++++++++++++++++++++++++++++++------ filter_pipeline/engine.py | 34 +++++++++++++++-- migrate_bookmarks.py | 11 +++--- templates/post_detail.html | 8 ++-- 4 files changed, 108 insertions(+), 23 deletions(-) diff --git a/app.py b/app.py index 1ec8c77..b1c221a 100644 --- a/app.py +++ b/app.py @@ -10,6 +10,7 @@ import time from pathlib import Path from werkzeug.utils import secure_filename from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, abort, session, jsonify +from markupsafe import escape, Markup from flask_login import LoginManager, login_user, logout_user, login_required, current_user from dotenv import load_dotenv from functools import lru_cache @@ -81,15 +82,36 @@ login_manager.login_message = 'Please log in to access this page.' # Initialize user service user_service = UserService() -# Initialize polling service +# Background services (polling scheduler + filter engine) are initialized +# lazily on the first request, NOT at import time. This keeps importing this +# module side-effect-free (no scheduler threads, no filter-engine init), so +# `from app import app` is safe for migration scripts and tests. The full +# app-factory split is deferred to Phase 1. from polling_service import polling_service -polling_service.init_app(app) -polling_service.start() - -# Initialize filter engine from filter_pipeline import FilterEngine -filter_engine = FilterEngine.get_instance() -logger.info(f"FilterEngine initialized with {len(filter_engine.get_available_filtersets())} filtersets") + +filter_engine = None # set lazily in _ensure_services_started() +_services_started = False + + +@app.before_request +def _ensure_services_started(): + """Start the polling scheduler and initialize the filter engine on the + first request, once. Kept out of module import so importing this module + has no side effects. + """ + global filter_engine, _services_started + if _services_started: + return + if filter_engine is None: + filter_engine = FilterEngine.get_instance() + logger.info( + f"FilterEngine initialized with " + f"{len(filter_engine.get_available_filtersets())} filtersets" + ) + polling_service.init_app(app) + polling_service.start() + _services_started = True # Initialize OAuth for Auth0 oauth = OAuth(app) @@ -114,6 +136,10 @@ def _is_safe_filterset(filterset): """Validate filterset name for security""" if not filterset or not isinstance(filterset, str): return False + # filter_engine is initialized lazily on the first request; if it has not + # been initialized yet, fail closed. + if filter_engine is None: + return False # Check against available filtersets from filter_engine allowed_filtersets = set(filter_engine.get_available_filtersets()) return filterset in allowed_filtersets and re.match(r'^[a-zA-Z0-9_-]+$', filterset) @@ -239,10 +265,40 @@ def _validate_user_settings(settings_str): # Add custom Jinja filters @app.template_filter('nl2br') def nl2br_filter(text): - """Convert newlines to
tags""" + """Convert newlines to
tags. + + Escapes the input first (so raw HTML in user content cannot inject + markup), then inserts
tags and marks the result safe. Use this + instead of `| safe | nl2br`, which left an XSS hole. + """ if not text: return text - return text.replace('\n', '
\n') + return Markup(str(escape(text)).replace('\n', '
\n')) + + +@app.template_filter('timeago') +def timeago_filter(timestamp): + """Format a unix timestamp as a relative time string ('3m ago', '5h ago', + '2d ago'), falling back to a date for older posts and 'Recently' for + missing/invalid input. Replaces the undefined `moment(...).fromNow()` + pattern that always rendered 'Recently'. + """ + if not timestamp: + return 'Recently' + try: + ts = float(timestamp) + except (TypeError, ValueError): + return 'Recently' + diff = time.time() - ts + if diff < 0: + return 'Recently' + if diff < 3600: + return f'{int(diff // 60)}m ago' + if diff < 86400: + return f'{int(diff // 3600)}h ago' + if diff < 604800: + return f'{int(diff // 86400)}d ago' + return time.strftime('%Y-%m-%d', time.localtime(ts)) @login_manager.user_loader @@ -1062,8 +1118,8 @@ def password_reset(token): password = request.form.get('password', '') confirm_password = request.form.get('confirm_password', '') - if not password or len(password) < 6: - flash('Password must be at least 6 characters', 'error') + if not password or len(password) < MIN_PASSWORD_LENGTH: + flash(f'Password must be at least {MIN_PASSWORD_LENGTH} characters', 'error') return render_template('password_reset.html') if password != confirm_password: diff --git a/filter_pipeline/engine.py b/filter_pipeline/engine.py index e79e5a4..d6220e9 100644 --- a/filter_pipeline/engine.py +++ b/filter_pipeline/engine.py @@ -184,10 +184,16 @@ class FilterEngine: if self.config.is_ai_enabled(): self._init_stages() - # If AI is disabled but filterset requires it, fall back to no_filter + # If AI is disabled but the filterset requires it, do NOT silently pass + # everything as no_filter. Pass the posts through (so the feed is not + # blanked) but mark every result as FAILED with an explicit error so the + # degradation is observable, not silent. if not self.config.is_ai_enabled() and filterset_name != 'no_filter': - logger.warning(f"AI disabled but '{filterset_name}' requires AI - falling back to 'no_filter'") - return self._process_no_filter(posts) + logger.warning( + f"AI disabled but filterset '{filterset_name}' requires AI - " + f"passing posts through unfiltered with FAILED status" + ) + return self._process_ai_disabled(filterset_name, posts) # Get pipeline stages for this filterset stage_names = self._get_stages_for_filterset(filterset_name) @@ -218,6 +224,28 @@ class FilterEngine: return results + def _process_ai_disabled(self, filterset_name: str, posts: List[Dict[str, Any]]) -> List[FilterResult]: + """Pass posts through unfiltered when the requested filterset needs AI + but AI is disabled. Unlike no_filter, every result is marked FAILED with + an explicit error so the degradation is observable rather than silent. + """ + results = [] + for post in posts: + result = FilterResult( + post_uuid=post.get('uuid', ''), + passed=True, # do not blank the feed + score=0.5, # neutral score + categories=[], + tags=[], + filterset_name=filterset_name, + processed_at=datetime.now(), + status=ProcessingStatus.FAILED, + error=f"AI disabled: filterset '{filterset_name}' requires AI; passed through unfiltered" + ) + results.append(result) + + return results + def _get_stages_for_filterset(self, filterset_name: str) -> List[str]: """Get pipeline stages to run for a filterset""" filterset = self.config.get_filterset(filterset_name) diff --git a/migrate_bookmarks.py b/migrate_bookmarks.py index bcb6352..4c5ac6e 100644 --- a/migrate_bookmarks.py +++ b/migrate_bookmarks.py @@ -5,7 +5,7 @@ Migration script to create the bookmarks table. import os import sys -from database import init_db +from database import init_db, db from flask import Flask # Add the current directory to Python path @@ -24,12 +24,13 @@ def main(): app = create_app() with app.app_context(): - # Initialize database - db = init_db(app) - + # Initialize database (init_db binds the shared `db` extension; it + # returns None, so use the module-level `db` directly). + init_db(app) + # Import models to register them from models import User, Session, PollSource, PollLog, Bookmark - + # Create all tables (will only create missing ones) db.create_all() diff --git a/templates/post_detail.html b/templates/post_detail.html index 29d70b8..41ab284 100644 --- a/templates/post_detail.html +++ b/templates/post_detail.html @@ -31,7 +31,7 @@ {{ post.source_display if post.source_display else ('r/' + post.source if post.platform == 'reddit' else post.source) }} {% endif %} - {{ moment(post.timestamp).fromNow() if moment else 'Recently' }} + {{ post.timestamp|timeago }} {% if post.url and not post.url.startswith('/') %} 🔗 {% endif %} @@ -48,7 +48,7 @@ {% if post.content %}
- {{ post.content | safe | nl2br }} + {{ post.content | nl2br }}
{% endif %} @@ -101,10 +101,10 @@
{{ comment.author }} - {{ moment(comment.timestamp).fromNow() if moment else 'Recently' }} + {{ comment.timestamp|timeago }}
- {{ comment.content | safe | nl2br }} + {{ comment.content | nl2br }}
- -
-

Content Actions

-
- -

- This will regenerate all HTML files with current templates and filters. -

-
-
diff --git a/templates/bookmarks.html b/templates/bookmarks.html index 3fac042..12942ae 100644 --- a/templates/bookmarks.html +++ b/templates/bookmarks.html @@ -136,7 +136,7 @@ async function loadBookmarks(page = 1) { try { document.getElementById('loading').style.display = 'block'; - const response = await fetch(`/api/bookmarks?page=${page}&per_page=20`); + const response = await fetch(`/api/v1/bookmarks?page=${page}&per_page=20`); const data = await response.json(); if (!response.ok) { @@ -226,7 +226,7 @@ async function removeBookmark(postId, button) { button.disabled = true; button.textContent = 'Removing...'; - const response = await fetch('/api/bookmark', { + const response = await fetch('/api/v1/bookmark', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/templates/dashboard.html b/templates/dashboard.html index 77d13bc..9ca1f14 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -708,7 +708,7 @@ document.addEventListener('DOMContentLoaded', function() { // Load platform configuration and communities async function loadPlatformConfig() { try { - const response = await fetch('/api/platforms'); + const response = await fetch('/api/v1/platforms'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -736,7 +736,7 @@ async function loadPlatformConfig() { // Load available filters async function loadFilters() { try { - const response = await fetch('/api/filters'); + const response = await fetch('/api/v1/filters'); const data = await response.json(); filtersData = data.filters || []; @@ -832,7 +832,7 @@ async function loadPosts(page = 1, community = '', platform = '', append = false if (filter || currentFilter) params.append('filter', filter || currentFilter); if (currentSearchQuery) params.append('q', currentSearchQuery); - const response = await fetch(`/api/posts?${params}`); + const response = await fetch(`/api/v1/posts?${params}`); const data = await response.json(); const newPosts = data.posts || []; paginationData = data.pagination || {}; @@ -1195,7 +1195,7 @@ function setupAutoRefresh() { if (currentPage === 1 && !currentCommunity && !currentPlatform) { try { // Check if new content is available by checking timestamp - const response = await fetch('/api/content-timestamp'); + const response = await fetch('/api/v1/content-timestamp'); const data = await response.json(); const lastContentUpdate = data.timestamp; diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..15b5aca --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,112 @@ +"""Shared pytest fixtures for BalanceBoard. + +The app fixture runs against an in-memory SQLite database (no Postgres +required) by monkeypatching ``database.init_db``. Filter-engine and +polling-service singletons are stubbed so request handlers do not start +background threads or hit the real filter pipeline during API contract +tests. +""" + +import pytest +from sqlalchemy.pool import StaticPool + + +def _sqlite_init_db(app): + """Replace the Postgres init_db with an in-memory SQLite setup.""" + from database import db + + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" + # A single in-memory DB shared across the test's connections. + app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"poolclass": StaticPool} + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + db.init_app(app) + with app.app_context(): + db.create_all() + + +@pytest.fixture +def app(monkeypatch): + import database + from app import create_app + + monkeypatch.setattr(database, "init_db", _sqlite_init_db) + application = create_app() + application.config.update(TESTING=True) + return application + + +@pytest.fixture +def client(app): + return app.test_client() + + +class _StubPolling: + """No-op polling service so ``before_request`` does not start threads.""" + + def init_app(self, app): + pass + + def start(self): + pass + + +class _StubFilterEngine: + """Passthrough filter engine for API contract tests.""" + + class _Config: + def get_filterset(self, name): + return None + + config = _Config() + + def apply_filterset(self, posts, filterset_name="no_filter", use_cache=True): + for p in posts: + p.setdefault("_filter_score", 0.5) + p.setdefault("_filter_categories", []) + p.setdefault("_filter_tags", []) + return posts + + def filter_comments(self, comments, filterset_name="no_filter"): + return comments + + def get_available_filtersets(self): + return ["no_filter"] + + +@pytest.fixture +def stub_services(app, monkeypatch): + """Patch the app's lazy service accessors so requests stay hermetic.""" + import app as app_module + + monkeypatch.setattr(app_module, "get_filter_engine", lambda: _StubFilterEngine()) + monkeypatch.setattr(app_module, "get_polling_service", lambda: _StubPolling()) + return app + + +class StubPostService: + """In-memory post/comment store for /api/v1 contract tests.""" + + def __init__(self, posts=None, comments=None): + self._posts = posts or {} + self._comments = comments or {} + + def load(self): + return self._posts, self._comments + + @staticmethod + def build_comment_tree(comments): + comment_dict = {c["uuid"]: {**c, "replies": []} for c in comments} + roots = [] + for c in comments: + parent = c.get("parent_comment_uuid") + if parent and parent in comment_dict: + comment_dict[parent]["replies"].append(comment_dict[c["uuid"]]) + else: + roots.append(comment_dict[c["uuid"]]) + return roots + + def source_counts(self): + return {} + + def latest_content_mtime(self): + return 0 \ No newline at end of file diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py new file mode 100644 index 0000000..3542527 --- /dev/null +++ b/tests/test_api_contracts.py @@ -0,0 +1,102 @@ +"""/api/v1 contract tests with monkeypatched post_service + filter engine. + +No live Postgres and no real filter pipeline: ``post_service`` is replaced with +an in-memory stub and ``get_filter_engine`` with a passthrough stub. These +assert the JSON shape the templates/SPA consume, so refactor regressions are +caught without a runtime stack. +""" + +import pytest + +from conftest import StubPostService, _StubFilterEngine + + +SAMPLE_POST = { + "uuid": "post-1", + "title": "Sample post", + "author": "alice", + "platform": "hackernews", + "source": "programming", + "score": 42, + "timestamp": 1700000000, + "url": "https://example.com/1", + "content": "Hello world", + "tags": ["tech"], +} + +SAMPLE_COMMENTS = [ + {"uuid": "c1", "post_uuid": "post-1", "content": "top", "score": 3, + "depth": 0, "parent_comment_uuid": None}, + {"uuid": "c2", "post_uuid": "post-1", "content": "reply", "score": 1, + "depth": 1, "parent_comment_uuid": "c1"}, +] + + +@pytest.fixture +def api_client(app, monkeypatch): + """App + client with stubbed post_service and filter engine.""" + import app as app_module + import blueprints.api as api_module + + monkeypatch.setattr(app_module, "get_filter_engine", lambda: _StubFilterEngine()) + monkeypatch.setattr(app_module, "get_polling_service", lambda: _StubPolling()) + monkeypatch.setattr(api_module, "get_filter_engine", lambda: _StubFilterEngine()) + monkeypatch.setattr( + api_module, "post_service", + StubPostService(posts={"post-1": SAMPLE_POST}, comments={"post-1": SAMPLE_COMMENTS}), + ) + return app.test_client() + + +def test_posts_endpoint_returns_paginated_shape(api_client): + resp = api_client.get("/api/v1/posts") + assert resp.status_code == 200 + data = resp.get_json() + assert set(data.keys()) >= {"posts", "pagination"} + assert set(data["pagination"].keys()) >= { + "current_page", "total_pages", "total_posts", "per_page", "has_next", "has_prev" + } + assert data["pagination"]["total_posts"] == 1 + post = data["posts"][0] + assert post["id"] == "post-1" + assert post["title"] == "Sample post" + assert post["platform"] == "hackernews" + assert post["url"] == "/post/post-1" + assert "filter_score" in post + + +def test_post_detail_returns_post_and_comments(api_client): + resp = api_client.get("/api/v1/posts/post-1") + assert resp.status_code == 200 + data = resp.get_json() + assert data["post"]["uuid"] == "post-1" + assert isinstance(data["comments"], list) + # The tree has one root with one nested reply. + assert data["comments"][0]["uuid"] == "c1" + assert data["comments"][0]["replies"][0]["uuid"] == "c2" + + +def test_post_detail_404_for_unknown(api_client): + resp = api_client.get("/api/v1/posts/does-not-exist") + assert resp.status_code == 404 + + +def test_comments_endpoint_returns_tree(api_client): + resp = api_client.get("/api/v1/comments/post-1") + assert resp.status_code == 200 + data = resp.get_json() + assert data["comments"][0]["uuid"] == "c1" + assert data["comments"][0]["replies"][0]["uuid"] == "c2" + + +def test_filters_endpoint_lists_filtersets(api_client): + resp = api_client.get("/api/v1/filters") + assert resp.status_code == 200 + data = resp.get_json() + assert "filters" in data + # Stub engine advertises no_filter; the list may be empty or contain it. + assert isinstance(data["filters"], list) + + +# Imported via the api_client fixture's monkeypatch; keep the name available. +from conftest import _StubPolling # noqa: E402, F401 \ No newline at end of file diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py new file mode 100644 index 0000000..48c226a --- /dev/null +++ b/tests/test_app_factory.py @@ -0,0 +1,58 @@ +"""App-factory tests: route registration and endpoint names are intact. + +These guard the Phase 1 refactor (module-level ``app`` → ``create_app()`` with +routes split under ``routes/`` and ``blueprints/``). They assert that the +endpoints referenced by templates via ``url_for(...)`` still resolve in the +Flask url_map. Runs against in-memory SQLite (no Postgres). +""" + +import pytest + +# Endpoints that templates reference via url_for(...) and that must survive +# the factory refactor. Sourced from the Jinja templates. +TEMPLATE_ENDPOINTS = [ + "login", + "signup", + "admin_setup", + "logout", + "serve_logo", + "serve_theme", + "static", + # API blueprint endpoints (mounted at /api/v1) + "api.posts", + "api.post_detail", + "api.comments", + "api.filters", + "api.bookmarks", + "api.platforms", +] + + +def test_create_app_returns_flask_app(app): + from flask import Flask + + assert isinstance(app, Flask) + + +def test_api_blueprint_mounted_under_v1(app): + rules = [r.rule for r in app.url_map.iter_rules()] + assert any(r.startswith("/api/v1/posts") for r in rules), rules + assert any(r.startswith("/api/v1/comments") for r in rules), rules + + +@pytest.mark.parametrize("endpoint", TEMPLATE_ENDPOINTS) +def test_template_endpoints_exist(app, endpoint): + # Flask stores endpoints as "." for blueprint views and + # bare names for app-level views. ``url_map`` has both. + all_endpoints = {r.endpoint for r in app.url_map.iter_rules()} + assert endpoint in all_endpoints, ( + f"endpoint '{endpoint}' missing from url_map; have: {sorted(all_endpoints)[:20]}..." + ) + + +def test_no_module_level_app_object(): + """Phase 1 removed the module-level ``app``; importing app.py must not + start a server or expose a global Flask app.""" + import app as app_module + + assert not hasattr(app_module, "app"), "app.py must not keep a module-level `app`" \ No newline at end of file diff --git a/tests/test_filter_pipeline.py b/tests/test_filter_pipeline.py new file mode 100644 index 0000000..564d7dd --- /dev/null +++ b/tests/test_filter_pipeline.py @@ -0,0 +1,103 @@ +"""Offline filter-pipeline tests. + +These do NOT require Flask or Postgres — only the filter_pipeline package (which +depends on stdlib + ``requests``). They exercise the registry-driven engine, +the offline plugin path, AI-disabled fail-open behavior, and comment filtering. +""" + +import pytest + +from filter_pipeline.engine import FilterEngine +from filter_pipeline.models import ProcessingStatus +from filter_pipeline.registry import ( + get_registered_plugins, + get_registered_stages, +) + + +@pytest.fixture +def engine(): + # Fresh engine (not the singleton) so test isolation holds. + return FilterEngine("filter_config.json", "filtersets.json") + + +def test_registry_discovers_builtin_stages_and_plugins(engine): + engine._init_stages() + stages = get_registered_stages() + for name in ["categorizer", "moderator", "filter", "ranker", + "plugins", "comment_filter"]: + assert name in stages, f"stage '{name}' not registered" + plugins = get_registered_plugins() + for name in ["keyword", "quality"]: + assert name in plugins, f"plugin '{name}' not registered" + + +def test_offline_filterset_runs_without_ai(engine): + # quality_filter uses pipeline_stages=['plugins','ranker'] only. + posts = [ + {"uuid": "p1", "title": "A fine Python programming title", + "content": "c" * 200, "score": 10, "replies": 2, + "platform": "reddit", "source": "python", "timestamp": 1700000000}, + {"uuid": "p2", "title": "bad", "content": "x", "score": 0, "replies": 0, + "platform": "reddit", "source": "python", "timestamp": 1700000000}, + ] + results = engine.process_batch(posts, "quality_filter") + assert results[0].passed is True + # Quality plugin rejects the 3-char title. + assert results[1].passed is False + assert any("QualityFilter" in t for t in results[1].tags) + + +def test_ai_filterset_does_not_blank_feed_when_ai_disabled(engine): + posts = [{"uuid": "p1", "title": "Hello world this is a fine title", + "content": "c" * 200, "score": 10, "replies": 2, + "platform": "hackernews", "source": "programming", + "timestamp": 1700000000}] + out = engine.apply_filterset(posts, "safe_content", use_cache=False) + assert len(out) == 1, "AI-disabled filterset must not blank the feed" + results = engine.process_batch(posts, "safe_content") + assert results[0].status == ProcessingStatus.FAILED + + +def test_no_filter_passes_everything(engine): + posts = [{"uuid": "p1", "title": "anything", "content": "", "score": 0, + "timestamp": 0}] + out = engine.apply_filterset(posts, "no_filter", use_cache=False) + assert len(out) == 1 + + +def test_comment_filter_individual_mode(engine): + comments = [ + {"uuid": "c1", "content": "this is long enough", "score": 5, + "depth": 0, "parent_comment_uuid": None}, + {"uuid": "c2", "content": "hi", "score": 1, "depth": 1, + "parent_comment_uuid": "c1"}, + ] + kept = engine.filter_comments(comments, "quality_filter") + assert [c["uuid"] for c in kept] == ["c1"] + + +def test_comment_filter_no_filter_passes_all(engine): + comments = [{"uuid": "c1", "content": "hi", "score": 1, "depth": 0, + "parent_comment_uuid": None}] + assert len(engine.filter_comments(comments, "no_filter")) == 1 + + +def test_comment_filter_unknown_filterset_fails_open(engine): + comments = [{"uuid": "c1", "content": "hi", "score": 1, "depth": 0, + "parent_comment_uuid": None}] + assert len(engine.filter_comments(comments, "no_such_filterset")) == 1 + + +def test_comment_tree_pruning_drops_branch_without_moderation(engine): + # safe_content comment rules require moderation.flags.is_safe == True. + # With no moderation data attached the field is None -> rule fails closed, + # so tree pruning removes the parent and its child. + comments = [ + {"uuid": "r", "content": "root", "score": 5, "depth": 0, + "parent_comment_uuid": None}, + {"uuid": "c", "content": "child", "score": 1, "depth": 1, + "parent_comment_uuid": "r"}, + ] + kept = engine.filter_comments(comments, "safe_content") + assert kept == [] \ No newline at end of file diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py new file mode 100644 index 0000000..df9ec54 --- /dev/null +++ b/tests/test_plugin_contract.py @@ -0,0 +1,90 @@ +"""Plugin/stage contract test (Phase 6). + +A drop-in stage and plugin, defined ONLY in this test module via the public +``@register_stage`` / ``@register_plugin`` decorators, are picked up by the +engine with zero edits to core files. This is the pluggability guarantee: a +new filter behavior is a new module + a config entry, never an edit to +``engine.py``. +""" + +from filter_pipeline.engine import FilterEngine +from filter_pipeline.models import FilterResult +from filter_pipeline.plugins.base import BaseFilterPlugin +from filter_pipeline.registry import ( + get_plugin_class, + get_stage_class, + register_plugin, + register_stage, +) +from filter_pipeline.stages.base_stage import BaseStage + + +@register_stage("sentinel_dropin_stage") +class SentinelStage(BaseStage): + """Drop-in stage that tags any result it sees.""" + + def get_name(self): + return "Sentinel" + + def process(self, post, result): + result.tags.append("sentinel_ran") + return result + + +@register_plugin("sentinel_dropin_plugin") +class SentinelPlugin(BaseFilterPlugin): + """Drop-in plugin: never rejects, returns a fixed score.""" + + def get_name(self): + return "SentinelPlugin" + + def should_filter(self, post, context=None): + return False + + def score(self, post, context=None): + return 0.9 + + +def test_dropin_stage_is_registered(): + assert get_stage_class("sentinel_dropin_stage") is SentinelStage + + +def test_dropin_plugin_is_registered(): + assert get_plugin_class("sentinel_dropin_plugin") is SentinelPlugin + + +def test_engine_instantiates_dropin_stage(): + eng = FilterEngine("filter_config.json", "filtersets.json") + eng._init_stages() + assert "sentinel_dropin_stage" in eng._stages + stage = eng._stages["sentinel_dropin_stage"] + # Running the stage through the contract it claims to implement works. + result = FilterResult(post_uuid="x", passed=True, score=0.5) + out = stage.process({"uuid": "x"}, result) + assert "sentinel_ran" in out.tags + + +def test_dropin_stage_can_be_selected_in_a_filterset(tmp_path): + """A filterset that lists the drop-in stage actually runs it. + + Builds a throwaway config + filterset on disk so no core file is edited. + """ + import json + + cfg = tmp_path / "cfg.json" + cfg.write_text(json.dumps({ + "ai": {"enabled": False}, + "cache": {"enabled": False}, + "pipeline": {"default_stages": ["sentinel_dropin_stage"], "enable_parallel": False}, + "plugins": {"enabled": [], "configs": {}}, + })) + fs = tmp_path / "fs.json" + fs.write_text(json.dumps({"custom": {"post_rules": {}, "comment_rules": {}}})) + + eng = FilterEngine(str(cfg), str(fs)) + eng._init_stages() + results = eng.process_batch( + [{"uuid": "p1", "title": "t", "content": "", "score": 0, "timestamp": 0}], + "custom", + ) + assert "sentinel_ran" in results[0].tags \ No newline at end of file diff --git a/themes/modern-card-ui/index.html b/themes/modern-card-ui/index.html index 74d8789..ffe6749 100644 --- a/themes/modern-card-ui/index.html +++ b/themes/modern-card-ui/index.html @@ -363,7 +363,7 @@ const originalText = button.querySelector('.bookmark-text').textContent; button.querySelector('.bookmark-text').textContent = 'Saving...'; - const response = await fetch('/api/bookmark', { + const response = await fetch('/api/v1/bookmark', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -412,7 +412,7 @@ const postId = button.getAttribute('data-post-id'); try { - const response = await fetch(`/api/bookmark-status/${postId}`); + const response = await fetch(`/api/v1/bookmark-status/${postId}`); const data = await response.json(); if (response.ok && data.bookmarked) { diff --git a/themes/template_prompt.txt b/themes/template_prompt.txt deleted file mode 100644 index 12a097d..0000000 --- a/themes/template_prompt.txt +++ /dev/null @@ -1,120 +0,0 @@ -# Template Creation Prompt for AI - -This document describes the data structures, helper functions, and conventions an AI needs to create or modify HTML templates for this social media archive system. - -## Data Structures Available - -### Post Data (when rendering posts) -- **Available in all post templates (card, list, detail):** - - platform: string (e.g., "reddit", "hackernews") - - id: string (unique post identifier) - - title: string - - author: string - - timestamp: integer (unix timestamp) - - score: integer (up/down vote score) - - replies: integer (number of comments) - - url: string (original post URL) - - content: string (optional post body text) - - source: string (optional subreddit/community) - - tags: array of strings (optional tags/flair) - - meta: object (optional platform-specific metadata) - - comments: array (optional nested comment tree - only in detail templates) - - post_url: string (generated: "{uuid}.html" - for local linking to detail pages) - -### Comment Data (when rendering comments) -- **Available in comment templates:** - - uuid: string (unique comment identifier) - - id: string (platform-specific identifier) - - author: string (comment author username) - - content: string (comment text) - - timestamp: integer (unix timestamp) - - score: integer (comment score) - - platform: string - - depth: integer (nesting level) - - children: array (nested replies) - - children_section: string (pre-rendered HTML of nested children) - -## Template Engine: Jinja2 - -Templates use Jinja2 syntax (`{{ }}` for variables, `{% %}` for control flow). - -### Important Filters: -- `|safe`: Mark content as safe HTML (for already-escaped content) -- Example: `{{ renderMarkdown(content)|safe }}` - -### Available Control Structures: -- `{% if variable %}...{% endif %}` -- `{% for item in array %}...{% endfor %}` -- `{% set variable = value %}` (create local variables) - -## Helper Functions Available - -Call these in templates using `{{ function(arg) }}`: - -### Time/Date Formatting: -- `formatTime(timestamp)` -> "HH:MM" -- `formatTimeAgo(timestamp)` -> "2 hours ago" -- `formatDateTime(timestamp)` -> "January 15, 2024 at 14:30" - -### Text Processing: -- `truncate(text, max_length)` -> truncated string with "..." -- `escapeHtml(text)` -> HTML-escaped version - -### Content Rendering: -- `renderMarkdown(text)` -> Basic HTML from markdown (returns already-escaped HTML) - -## Template Types - -### Card Template (for index/listing pages) -- Used for summary view of posts -- Links should use `post_url` to point to local detail pages -- Keep concise - truncated content, basic info - -### List Template (compact listing) -- Even more compact than cards -- Vote scores, basic metadata, title link - -### Detail Template (full post view) -- Full content, meta information -- Source link uses `url` (external) -- Must include `{{comments_section|safe}}` for rendered comments - -### Comment Template (nested comments) -- Recursive rendering with depth styling -- Children rendered as flattened HTML in `children_section` - -## Convenience Data Added by System - -In `generate_html.py`, `post_url` is added to each post before rendering: `{post['uuid']}.html` - -This allows templates to link to local detail pages instead of external Reddit. - -## CSS Classes Convention - -Templates use semantic CSS classes: -- Post cards: `.post-card`, `.post-header`, `.post-meta`, etc. -- Comments: `.comment`, `.comment-header`, `.comment-body`, etc. -- Platform: `.platform-{platform}` for platform-specific styling - -## Examples - -### Conditional Rendering: -``` -{% if content %} -

{{ renderMarkdown(content)|safe }}

-{% endif %} -``` - -### Looping Tags: -``` -{% for tag in tags if tag %} - {{ tag }} -{% endfor %} -``` - -### Styling by Depth (comments): -``` -
-``` - -When creating new templates, follow these patterns and use the available data and helper functions appropriately.