From 3c69de9d554fdfb9d40201bfad28fc03abc4b22d Mon Sep 17 00:00:00 2001 From: retoor Date: Tue, 8 Sep 2026 03:44:30 +0200 Subject: [PATCH] Add Happy 404, featured/related sidebars, and next-post nav to the post page Happy 404: an HTML 404 (unmatched route, or an explicit not-found inside a real route) now renders a random existing post instead of the error page, using the exact same context builder as a real post view. Toggle is the happy_404_enabled site setting (default on, /admin/settings); JSON/API requests and a handful of excluded prefixes are never affected. The pool of candidate slugs is cached in-process and resampled periodically so it stays fast and eventually cycles the whole posts table; on any internal failure it falls straight through to the real 404 page. Applying this everywhere surfaced ~60 existing tests that asserted a literal 404 for a legitimate resource-not-found flow (deleted post, unknown container, wrong project slug, etc.) - each now disables the setting for the duration of that specific check and restores it after, so the underlying not-found behavior stays covered independently of the new feature. Post page also gained, all built on the same shared post_page_context() so they render identically on both a real post and a happy-404 page: - A left sidebar (three separate cards, matching /feed's sidebar-card convention) for "Gists from {author}", "Projects from {author}" (private projects filtered through the normal visibility check), and "Related Discussions" - each cached per author and invalidated on create/edit/ delete so new content shows up immediately. - A right column reusing /feed's exact Daily Topic widget class for up to three "Featured" articles (the existing but previously-unused `featured` news flag), cached as a pool with per-request random sampling. - A "Next post -> " link beside "Back to Feed", pointing at the next older post site-wide (blocked authors skipped). Wired through the same next_url mechanism already used for listing pagination, so it emits a real backend-rendered tag for SEO, not just a visible link. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BWJy6PrMMt5hwWxQwia2rd --- README.md | 22 +++ devplacepy/content.py | 17 +++ devplacepy/database/CLAUDE.md | 2 + devplacepy/database/__init__.py | 5 +- devplacepy/database/content.py | 73 +++++++++ devplacepy/database/schema.py | 1 + devplacepy/happy404.py | 70 +++++++++ devplacepy/main.py | 24 +-- devplacepy/models.py | 1 + devplacepy/routers/CLAUDE.md | 13 +- devplacepy/routers/posts.py | 85 +++++++---- devplacepy/schemas/listings.py | 4 + .../services/devii/actions/catalog/admin.py | 1 + devplacepy/static/css/base.css | 3 - devplacepy/static/css/post.css | 49 ++++++- devplacepy/templates/admin_settings.html | 9 ++ .../templates/docs/architecture-backend.html | 4 + devplacepy/templates/post.html | 95 ++++++++++-- pyproject.toml | 2 +- tests/api/admin/awards.py | 13 +- tests/api/admin/bots/index.py | 16 +- tests/api/admin/media/purge.py | 13 +- tests/api/admin/trash/restore.py | 11 +- tests/api/awards/index.py | 15 +- tests/api/battles/events.py | 13 +- tests/api/block/visibility.py | 14 +- tests/api/containers.py | 9 +- tests/api/content/permissions.py | 6 +- tests/api/docs/downloadmd.py | 17 ++- tests/api/docs/index.py | 6 +- tests/api/error/pages.py | 76 +++++++++- tests/api/forks.py | 15 +- tests/api/issues/planning.py | 7 +- tests/api/posts/create.py | 6 +- tests/api/posts/featured.py | 118 +++++++++++++++ tests/api/posts/nextpost.py | 72 +++++++++ tests/api/posts/sidebar.py | 138 ++++++++++++++++++ tests/api/profile/delete.py | 14 +- tests/api/profile/index.py | 17 ++- tests/api/proxy.py | 15 +- tests/api/tools/deepsearch/export.py | 7 +- tests/api/tools/deepsearch/session.py | 16 +- tests/api/tools/isslop.py | 18 ++- tests/api/tools/seo.py | 16 +- tests/api/topics.py | 16 +- tests/api/zips/download.py | 14 +- tests/conftest.py | 3 + tests/e2e/admin/auditlog.py | 16 +- tests/e2e/admin/containers/index.py | 16 +- tests/e2e/admin/containers/manage.py | 7 +- tests/e2e/admin/services/data.py | 18 ++- tests/e2e/docs.py | 15 +- tests/e2e/feed.py | 13 +- tests/e2e/forks.py | 15 +- tests/e2e/game/farm.py | 18 ++- tests/e2e/issues/index.py | 17 ++- tests/e2e/issues/jobs.py | 17 ++- tests/e2e/news.py | 16 +- tests/e2e/projects/index.py | 21 ++- tests/e2e/reports/ui.py | 20 ++- 60 files changed, 1225 insertions(+), 165 deletions(-) create mode 100644 devplacepy/happy404.py create mode 100644 tests/api/posts/featured.py create mode 100644 tests/api/posts/nextpost.py create mode 100644 tests/api/posts/sidebar.py diff --git a/README.md b/README.md index 107873d..301a204 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,7 @@ Operational behavior is tunable live from `/admin/settings` (stored in `site_set | `registration_open` | `1` | When `0`, new sign-ups are rejected | | `maintenance_mode` | `0` | When `1`, non-admins see the maintenance page; admins retain access | | `maintenance_message` | scheduled-maintenance text | Message shown during maintenance | +| `happy_404_enabled` | `1` | When `1`, an HTML page that would otherwise 404 renders a random existing post instead ("Happy 404"); JSON/API requests always get a real 404 regardless. See [Happy 404](#happy-404) | | `customization_enabled` | `1` | When `0`, no user CSS/JS customization is injected on any page | | `customization_js_enabled` | `1` | When `0`, user custom CSS is still served but custom JavaScript is suppressed | | `audit_log_retention_days` | `90` | Audit rows older than this are pruned daily by the Audit retention service; `0` disables pruning | @@ -342,6 +343,27 @@ curl -H "Accept: application/json" https://your-host/feed curl -H "Accept: application/json" -X POST -d "content=hi&title=T&topic=devlog" https://your-host/posts/create ``` +## Happy 404 + +Instead of a bare error page, an HTML request that would 404 (an unmatched route, or an app route +raising `not_found()` for a missing resource) instead renders a random existing post at that URL, +using the exact same template and context as the real `/posts/{slug}` page. JSON/API requests are +unaffected and still get a normal `404` - the substitution only ever applies to a browser HTML +navigation. + +- **Toggle:** `happy_404_enabled` site setting (`/admin/settings`, on by default). +- **Scope:** any HTML `GET` 404, app-wide - not just under `/posts`. +- **Performance:** a small pool of random post slugs is cached in-process for a few minutes and + refreshed with a fresh random sample on expiry, so every request only does an in-memory pick plus + one indexed lookup - no per-request full-table scan - while the pool composition still cycles + through the whole `posts` table over time. +- **SEO safety:** the substituted page is always marked `noindex,nofollow` so the decoy URL is never + indexed under the wrong address. +- **Fail-closed:** any error while building the substitute page falls straight through to the normal + 404 page - this feature can never turn a real error into a worse one. + +Implementation: `devplacepy/happy404.py`, wired into the `404` exception handler in `main.py`. + ## XML-RPC bridge The full REST API is also reachable over XML-RPC at `/xmlrpc`. A standalone forking XML-RPC diff --git a/devplacepy/content.py b/devplacepy/content.py index 244289b..03f68e2 100644 --- a/devplacepy/content.py +++ b/devplacepy/content.py @@ -38,6 +38,8 @@ from devplacepy.database import ( get_int_setting, _now_iso, db, + get_user_recent_items, + invalidate_user_recent_cache, ) from devplacepy.utils import ( time_ago, @@ -133,6 +135,18 @@ def can_view_project(project: dict | None, user: dict | None) -> bool: return not _owner_is_admin(project) +def get_user_sidebar_gists(user_uid: str, limit: int = 5) -> list[dict]: + return get_user_recent_items("gists", user_uid)[:limit] + + +def get_user_sidebar_projects( + user_uid: str, viewer: dict | None, limit: int = 5 +) -> list[dict]: + rows = get_user_recent_items("projects", user_uid) + visible = [p for p in rows if can_view_project(p, viewer)] + return visible[:limit] + + def owns_instance( instance: dict | None, project: dict | None, user: dict | None ) -> bool: @@ -264,6 +278,7 @@ def create_content_item( from devplacepy.templating import clear_user_projects_cache clear_user_projects_cache(user["uid"]) + invalidate_user_recent_cache(table_name, user["uid"]) award_rewards(user["uid"], xp, badge) if attachment_uids: link_attachments(attachment_uids, target_type, uid) @@ -678,6 +693,7 @@ def edit_content_item( "updated_at": datetime.now(timezone.utc).isoformat(), } table.update({"uid": item["uid"], **update_fields}, ["uid"]) + invalidate_user_recent_cache(table_name, item["user_uid"]) record_screening( screening, target_type=kind, @@ -769,6 +785,7 @@ def delete_content_item( soft_delete_all_project_files(item["uid"], actor) soft_delete_fork_relations(item["uid"], actor) clear_user_projects_cache(item["user_uid"]) + invalidate_user_recent_cache(table_name, item["user_uid"]) soft_delete(table_name, actor, stamp=stamp, uid=item["uid"]) logger.info(f"{table_name} {item['uid']} soft-deleted by {user['username']}") audit.record( diff --git a/devplacepy/database/CLAUDE.md b/devplacepy/database/CLAUDE.md index 1826658..b5e4cf4 100644 --- a/devplacepy/database/CLAUDE.md +++ b/devplacepy/database/CLAUDE.md @@ -211,6 +211,7 @@ Site settings are seeded on startup (`site_settings` table): | `maintenance_message` | scheduled-maintenance text | Body shown on the maintenance 503 page | | `customization_enabled` | `"1"` | When `"0"`, `custom_css_tag`/`custom_js_tag` inject nothing (feature off) | | `customization_js_enabled` | `"1"` | When `"0"`, custom CSS still serves but custom JS is suppressed | +| `happy_404_enabled` | `"1"` | When `"0"`, `happy404.render()` is a no-op and a real 404 page is shown; when on, an HTML 404 renders a random existing post instead (see below) | | `moderation_sla_hours` | `"24"` | The published moderation response window; the admin queue badge turns red past it | | `moderation_filter_mode` | `"review"` | `off`/`label`/`review`/`block` - how the content filter acts on a match | | `moderation_filter_review_score` | `"2"` | Rule score at which a match becomes a report rather than a label | @@ -239,6 +240,7 @@ Operational settings - read sites and rules: | `service__enabled` / `service__command` / `service__log_size` | `BaseService` reconciling loop | Generic per-service controls written by the Services tab; the loop reconciles within ~1s | | `session_max_age_days` / `session_remember_days` | `auth.py` signup + login | Multiplied by `SECONDS_PER_DAY`; passed to `create_session(uid, max_age)` so the cookie and the DB session row expire together | | `registration_open` | `auth.py` `signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed | +| `happy_404_enabled` | `happy404.render()`, called from the `404` handler in `main.py` | Gates the whole feature; checked per-request via the normal 60s `get_setting` TTL cache, so a toggle takes effect within a minute across all workers with no restart | **Booleans are ` + + + + When enabled, a page that would otherwise 404 renders a random existing post instead. HTML requests only - JSON/API clients still get a real 404. + +
diff --git a/devplacepy/templates/docs/architecture-backend.html b/devplacepy/templates/docs/architecture-backend.html index 2078c2d..20459b0 100644 --- a/devplacepy/templates/docs/architecture-backend.html +++ b/devplacepy/templates/docs/architecture-backend.html @@ -9,6 +9,10 @@ How a request flows through middleware to a router, how the database layer is bu `main.py` builds the FastAPI app, mounts `/static`, registers every router with its prefix, installs the middlewares, and registers global 404, 500, and `RequestValidationError` handlers that render `error.html` through the shared `templates` instance. +### Happy 404 + +Before falling back to `error.html`, the 404 handler calls `happy404.render(request)` (`devplacepy/happy404.py`). For an HTML `GET` request outside the API-ish path prefixes (`/api`, `/dbapi`, `/openai`, `/xmlrpc`, `/swagger`, `/openapi.json`), it picks a random slug from a process-local pool of existing post slugs, loads that post through the normal `content.load_detail` pipeline, and renders `post.html` (the exact context built by `routers/posts.py`'s `post_page_context`, reused by the real `/posts/{slug}` route) at the requested URL - with `meta_robots` forced to `noindex,nofollow` so the decoy URL is never indexed. The pool (`POOL_SIZE` = 100 slugs) is refreshed from `SELECT slug, uid FROM posts WHERE deleted_at IS NULL ORDER BY RANDOM() LIMIT :limit` on a `TTLCache` (`POOL_TTL_SECONDS` = 300s) so the expensive random-order scan runs at most once per five minutes per worker, while each request only does an O(1) in-memory `random.choice` plus one indexed slug lookup - fast enough to run on every 404. A fresh sample every TTL window means the pool eventually surfaces every post, not just a fixed subset. JSON/API requests are unaffected (checked before `happy404.render` is even called) and are excluded a second time via the path-prefix guard as defense in depth. The whole function is wrapped in a catch-all that logs and returns `None` on any failure, falling through to the normal `error.html` 404 - a bug in this feature can never break the 404 page itself. Toggle with the `happy_404_enabled` site setting (`/admin/settings`, default on). + ## Middleware Seven HTTP middlewares run as a stack around every request, listed outermost first. `response_timing` is the outermost, `refresh_db_snapshot` the innermost, and a `GZipMiddleware` (responses over 512 bytes) wraps the whole stack on top: diff --git a/devplacepy/templates/post.html b/devplacepy/templates/post.html index 1ddb855..2f6257d 100644 --- a/devplacepy/templates/post.html +++ b/devplacepy/templates/post.html @@ -1,12 +1,68 @@ {% extends "base.html" %} {% from "_macros.html" import modal %} {% block extra_head %} + {% endblock %} {% block content %} -
- ← Back to Feed +
+ {% if author_gists or author_projects or related_posts %} + + {% endif %} +
+
+ ← Back to Feed + {% if next_post_url %} + + {% endif %} +
@@ -103,19 +159,32 @@ {% with target_uid=post['uid'], target_type="post" %} {% include "_comment_section.html" %} {% endwith %} +
- {% if related_posts %} - + {% endfor %} + {% endif %}
{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index e3dc038..d6c1158 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devplacepy" -version = "1.0.5" +version = "1.0.6" description = "DevPlace - The Developer Social Network" requires-python = ">=3.12" dependencies = [ diff --git a/tests/api/admin/awards.py b/tests/api/admin/awards.py index 878e6ec..e6b6a81 100644 --- a/tests/api/admin/awards.py +++ b/tests/api/admin/awards.py @@ -1,12 +1,13 @@ # retoor import io +import time import requests from datetime import datetime, timezone from PIL import Image -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS from devplacepy.attachments import store_attachment -from devplacepy.database import get_table +from devplacepy.database import get_table, set_setting from devplacepy.utils import generate_uid, make_combined_slug JSON = {"Accept": "application/json"} @@ -87,7 +88,13 @@ def test_admin_revoke_soft_deletes_and_recomputes(seeded_db): assert get_table("attachments").find_one(uid=att512).get("deleted_at") bob = get_table("users").find_one(username="bob_test") assert bob.get("award_count") == 0 - assert requests.get(f"{BASE_URL}/awards/{slug}/64").status_code == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + assert requests.get(f"{BASE_URL}/awards/{slug}/64").status_code == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_media_gallery_hides_revoked_attachment(seeded_db): diff --git a/tests/api/admin/bots/index.py b/tests/api/admin/bots/index.py index 7cf1a97..9277ada 100644 --- a/tests/api/admin/bots/index.py +++ b/tests/api/admin/bots/index.py @@ -1,8 +1,10 @@ # retoor +import time + import requests -from tests.conftest import BASE_URL -from devplacepy.database import get_table, refresh_snapshot +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, refresh_snapshot, set_setting JSON = {"Accept": "application/json"} @@ -49,8 +51,14 @@ def test_bots_data_shape_for_admin(app_server, seeded_db): def test_bots_frame_missing_is_404(app_server, seeded_db): admin = _admin() - r = admin.get(f"{BASE_URL}/admin/bots/999/frame.jpg", allow_redirects=False) - assert r.status_code == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + r = admin.get(f"{BASE_URL}/admin/bots/999/frame.jpg", allow_redirects=False) + assert r.status_code == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_bots_frame_requires_admin(app_server, seeded_db): diff --git a/tests/api/admin/media/purge.py b/tests/api/admin/media/purge.py index 5215931..11207ff 100644 --- a/tests/api/admin/media/purge.py +++ b/tests/api/admin/media/purge.py @@ -1,11 +1,12 @@ # retoor import io +import time import uuid import requests from PIL import Image -from tests.conftest import BASE_URL -from devplacepy.database import get_table +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, set_setting JSON_media = {"Accept": "application/json"} def _png_bytes_media(color=(200, 30, 30)): buf = io.BytesIO() @@ -130,4 +131,10 @@ def test_admin_purge_hard_deletes(seeded_db): # row gone (not in trash, cannot restore) and file removed from disk trash = admin.get(f"{BASE_URL}/admin/media", headers=JSON_media).json() assert all(m["uid"] != uid for m in trash["media"]) - assert owner.get(f"{BASE_URL}{file_url}").status_code == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + assert owner.get(f"{BASE_URL}{file_url}").status_code == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) diff --git a/tests/api/admin/trash/restore.py b/tests/api/admin/trash/restore.py index eda55ad..b76efa5 100644 --- a/tests/api/admin/trash/restore.py +++ b/tests/api/admin/trash/restore.py @@ -2,7 +2,8 @@ import time import requests -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import set_setting JSON_trash = {"Accept": "application/json"} _counter_trash = [0] @@ -146,7 +147,13 @@ def test_restore_revoked_award_recomputes_stats(seeded_db): row = get_table("users").find_one(uid=bob["uid"]) assert row.get("award_count") == 1 assert row.get("last_award_uid") == uid - assert requests.get(f"{BASE_URL}/awards/{slug}/256").status_code in (302, 404) + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + assert requests.get(f"{BASE_URL}/awards/{slug}/256").status_code in (302, 404) + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _create_quiz_trash(session): diff --git a/tests/api/awards/index.py b/tests/api/awards/index.py index e2ff663..a7a73a1 100644 --- a/tests/api/awards/index.py +++ b/tests/api/awards/index.py @@ -1,15 +1,26 @@ # retoor import io +import time +import pytest import requests from datetime import datetime, timezone from PIL import Image -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS from devplacepy.attachments import store_attachment -from devplacepy.database import get_table +from devplacepy.database import get_table, set_setting from devplacepy.utils import generate_uid, make_combined_slug +@pytest.fixture(scope="module", autouse=True) +def _disable_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + + def _png(): buf = io.BytesIO() Image.new("RGBA", (32, 32), (20, 40, 60, 255)).save(buf, format="PNG") diff --git a/tests/api/battles/events.py b/tests/api/battles/events.py index ecfc412..a53960d 100644 --- a/tests/api/battles/events.py +++ b/tests/api/battles/events.py @@ -5,7 +5,8 @@ import time import requests from tests.api.battles._helpers import _create_war_post, _fight, _join, _session_battles -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import set_setting def test_events_ordered_and_incremental(app_server): @@ -39,5 +40,11 @@ def test_events_ordered_and_incremental(app_server): def test_events_unknown_battle_404(app_server): - r = requests.get(f"{BASE_URL}/battles/nope/events") - assert r.status_code == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + r = requests.get(f"{BASE_URL}/battles/nope/events") + assert r.status_code == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) diff --git a/tests/api/block/visibility.py b/tests/api/block/visibility.py index 4567296..146c170 100644 --- a/tests/api/block/visibility.py +++ b/tests/api/block/visibility.py @@ -4,8 +4,8 @@ import time from uuid import uuid4 from datetime import datetime, timezone import requests -from tests.conftest import BASE_URL -from devplacepy.database import get_table +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, set_setting from devplacepy.utils import make_combined_slug JSON = {"Accept": "application/json"} @@ -110,8 +110,14 @@ def test_blocked_author_post_detail_404(app_server): post = _new_post(author_session) blocker_session.post(f"{BASE_URL}/block/{author}", allow_redirects=False) - r = blocker_session.get(f"{BASE_URL}/posts/{post['slug']}") - assert r.status_code == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + r = blocker_session.get(f"{BASE_URL}/posts/{post['slug']}") + assert r.status_code == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_blocked_author_comment_hidden_on_detail(app_server): diff --git a/tests/api/containers.py b/tests/api/containers.py index 791658e..5b896a2 100644 --- a/tests/api/containers.py +++ b/tests/api/containers.py @@ -1,10 +1,11 @@ # retoor from datetime import timedelta +import time import pytest import requests -from tests.conftest import BASE_URL, run_async -from devplacepy.database import db, get_table, init_db, refresh_snapshot +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS, run_async +from devplacepy.database import db, get_table, init_db, refresh_snapshot, set_setting from devplacepy import config, project_files from devplacepy.services.containers import api, store, runtime from devplacepy.services.containers.backend.base import Mount, PortMapping, RunSpec @@ -98,12 +99,16 @@ def test_http_ingress_proxy(app_server): } ) refresh_snapshot() + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) try: r = requests.get(f"{BASE_URL}/p/{slug}/foo") assert r.status_code == 200, r.text assert "hello from upstream" in r.text and "/foo" in r.text assert requests.get(f"{BASE_URL}/p/does-not-exist-slug").status_code == 404 finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) httpd.shutdown() get_table("instances").delete(uid=uid) refresh_snapshot() diff --git a/tests/api/content/permissions.py b/tests/api/content/permissions.py index e914da1..2a992f1 100644 --- a/tests/api/content/permissions.py +++ b/tests/api/content/permissions.py @@ -3,7 +3,7 @@ import time import pytest import requests -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS from devplacepy.database import get_table, refresh_snapshot, set_setting JSON = {"Accept": "application/json"} @@ -19,9 +19,13 @@ def _perm_settings(app_server): "maintenance_mode": "0", "max_attachments_per_resource": "10", "allowed_file_types": "", + "happy_404_enabled": "0", }.items(): set_setting(key, value) + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _member(): diff --git a/tests/api/docs/downloadmd.py b/tests/api/docs/downloadmd.py index c81624a..2da1d59 100644 --- a/tests/api/docs/downloadmd.py +++ b/tests/api/docs/downloadmd.py @@ -3,11 +3,24 @@ import html import json import re +import time +import pytest import requests -from tests.conftest import BASE_URL -from devplacepy.database import get_table +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, set_setting from devplacepy.docs_api import API_GROUPS from devplacepy.utils import clear_user_cache + + +@pytest.fixture(scope="module", autouse=True) +def _disable_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + + def _configs_by_id(text): configs = re.findall(r"data-config='(.*?)'", text, re.S) return { diff --git a/tests/api/docs/index.py b/tests/api/docs/index.py index 3eb9fe8..7e84af7 100644 --- a/tests/api/docs/index.py +++ b/tests/api/docs/index.py @@ -4,7 +4,7 @@ import time from datetime import datetime, timezone import pytest import requests -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS from devplacepy.database import get_table, refresh_snapshot, set_setting from devplacepy.utils import generate_uid, make_combined_slug JSON_audit_log = {"Accept": "application/json"} @@ -29,9 +29,13 @@ def _audit_test_settings(app_server): "session_remember_days": "30", "news_service_interval": "3600", "news_grade_threshold": "7", + "happy_404_enabled": "0", }.items(): set_setting(key, value) + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _db_user(name): # the user is created by the server subprocess; refresh the test-process # SQLite snapshot before reading it back across the process boundary. diff --git a/tests/api/error/pages.py b/tests/api/error/pages.py index 7f97191..f6783b2 100644 --- a/tests/api/error/pages.py +++ b/tests/api/error/pages.py @@ -1,10 +1,80 @@ # retoor +import time + import requests -from tests.conftest import BASE_URL + +from devplacepy.database import set_setting +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS + +JSON = {"Accept": "application/json"} + + +def _set_happy_404(enabled: bool) -> None: + set_setting("happy_404_enabled", "1" if enabled else "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + + +def _login(seeded_db, who="alice"): + creds = seeded_db[who] + session = requests.Session() + session.post( + f"{BASE_URL}/auth/login", + data={"email": creds["email"], "password": creds["password"]}, + ) + return session def test_404_renders_error_page(app_server): - r = requests.get(f"{BASE_URL}/this-page-does-not-exist-xyz") + _set_happy_404(False) + try: + r = requests.get(f"{BASE_URL}/this-page-does-not-exist-xyz") + assert r.status_code == 404 + assert "404" in r.text or "not found" in r.text.lower() + finally: + _set_happy_404(True) + + +def test_happy_404_renders_a_real_post(app_server, seeded_db): + session = _login(seeded_db) + title = f"Happy404 Marker {int(time.time() * 1000)}" + created = session.post( + f"{BASE_URL}/posts/create", + headers=JSON, + data={ + "title": title, + "content": "content seeded so the happy 404 pool has something to pick.", + "topic": "devlog", + }, + ).json()["data"] + assert created["uid"] + + _set_happy_404(True) + r = requests.get(f"{BASE_URL}/this-path-was-never-registered-anywhere") + assert r.status_code == 200 + assert 'class="post-detail"' in r.text + assert "Page not found" not in r.text + assert 'name="robots" content="noindex,nofollow"' in r.text + + +def test_happy_404_disabled_falls_back_to_error_page(app_server): + _set_happy_404(False) + try: + r = requests.get(f"{BASE_URL}/another-path-that-does-not-exist") + assert r.status_code == 404 + assert "Page not found" in r.text + finally: + _set_happy_404(True) + + +def test_happy_404_never_applies_to_json_requests(): + _set_happy_404(True) + r = requests.get(f"{BASE_URL}/yet-another-missing-path", headers=JSON) + assert r.status_code == 404 + assert r.json()["error"]["status"] == 404 + + +def test_happy_404_never_applies_to_api_paths(): + _set_happy_404(True) + r = requests.get(f"{BASE_URL}/api/this-devrant-route-does-not-exist") assert r.status_code == 404 - assert "404" in r.text or "not found" in r.text.lower() diff --git a/tests/api/forks.py b/tests/api/forks.py index 47aef60..3f844eb 100644 --- a/tests/api/forks.py +++ b/tests/api/forks.py @@ -1,14 +1,15 @@ # retoor import asyncio +import time from datetime import datetime, timezone from pathlib import Path import pytest -from devplacepy.database import init_db, get_table, refresh_snapshot +from devplacepy.database import init_db, get_table, refresh_snapshot, set_setting from devplacepy import project_files from devplacepy.services.jobs import queue from devplacepy.services.jobs.fork_service import ForkService -from tests.conftest import run_async +from tests.conftest import CACHE_VERSION_PROPAGATION_SECONDS, run_async @pytest.fixture(autouse=True) def _init_db_fork_jobs(): init_db() @@ -109,5 +110,11 @@ def _enqueue(source_uid, owner_uid, title="My Fork"): def test_fork_status_http_unknown_returns_404(app_server): import requests from tests.conftest import BASE_URL - r = requests.get(f"{BASE_URL}/forks/nonexistent-uid") - assert r.status_code == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + r = requests.get(f"{BASE_URL}/forks/nonexistent-uid") + assert r.status_code == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) diff --git a/tests/api/issues/planning.py b/tests/api/issues/planning.py index 44bf81b..efc64e7 100644 --- a/tests/api/issues/planning.py +++ b/tests/api/issues/planning.py @@ -1,12 +1,13 @@ # retoor import json +import time import pytest import requests from devplacepy.database import get_table, refresh_snapshot, set_setting -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS @pytest.fixture(scope="module", autouse=True) @@ -17,9 +18,13 @@ def _planning_settings(app_server): set_setting("gitea_repo", "pydevplace") set_setting("gitea_token", "planning-test-token") set_setting("issue_ai_enhance", "0") + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) yield set_setting("gitea_base_url", "") set_setting("gitea_token", "") + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _admin(seeded_db): diff --git a/tests/api/posts/create.py b/tests/api/posts/create.py index 751b551..f9734da 100644 --- a/tests/api/posts/create.py +++ b/tests/api/posts/create.py @@ -4,7 +4,7 @@ import time from datetime import datetime, timezone import pytest import requests -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS from devplacepy.database import get_table, refresh_snapshot, set_setting from devplacepy.utils import generate_uid, make_combined_slug JSON_audit_log = {"Accept": "application/json"} @@ -29,9 +29,13 @@ def _audit_test_settings(app_server): "session_remember_days": "30", "news_service_interval": "3600", "news_grade_threshold": "7", + "happy_404_enabled": "0", }.items(): set_setting(key, value) + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _db_user(name): # the user is created by the server subprocess; refresh the test-process # SQLite snapshot before reading it back across the process boundary. diff --git a/tests/api/posts/featured.py b/tests/api/posts/featured.py new file mode 100644 index 0000000..8a23105 --- /dev/null +++ b/tests/api/posts/featured.py @@ -0,0 +1,118 @@ +# retoor + +import time + +import requests + +from devplacepy.database import get_table +from tests.conftest import BASE_URL + +JSON = {"Accept": "application/json"} +_counter = [0] + + +def _unique(prefix="feat"): + _counter[0] += 1 + return f"{prefix}{int(time.time() * 1000)}{_counter[0]}" + + +def _signup(): + name = _unique("featuser") + session = requests.Session() + session.post( + f"{BASE_URL}/auth/signup", + data={ + "username": name, + "email": f"{name}@t.dev", + "password": "secret123", + "confirm_password": "secret123", + "birth_date": "1990-01-01", + "accept_terms": "1", + }, + allow_redirects=True, + ) + return session + + +def _new_post(session): + return session.post( + f"{BASE_URL}/posts/create", + headers=JSON, + data={ + "title": _unique("featpost"), + "content": "content for the featured topics test post", + "topic": "devlog", + }, + ).json()["data"] + + +def _seed_featured_article(): + uid = _unique("featnews") + get_table("news").insert( + { + "uid": uid, + "title": _unique("Featured Article"), + "slug": _unique("featured-article"), + "external_id": uid, + "status": "published", + "source_name": "test", + "url": "https://example.com/article", + "description": "a featured article for the post-page test", + "content": "", + "synced_at": "2024-01-01T00:00:00", + "grade": 5, + "ai_grade": 5, + "show_on_landing": 0, + "featured": 1, + "featured_locked": 1, + "landing_locked": 0, + "author": "", + "article_published": "", + "image_url": "", + "has_unique_image": 0, + "deleted_at": None, + "deleted_by": None, + } + ) + return uid + + +def test_post_page_shows_featured_topics(app_server): + session = _signup() + post = _new_post(session) + _seed_featured_article() + + r = requests.get(f"{BASE_URL}/posts/{post['slug']}") + assert r.status_code == 200 + assert 'class="feed-right"' in r.text + assert "daily-topic-card" in r.text + + +def test_post_page_json_includes_featured_topics(app_server): + session = _signup() + post = _new_post(session) + _seed_featured_article() + + r = requests.get(f"{BASE_URL}/posts/{post['slug']}", headers=JSON) + assert r.status_code == 200 + data = r.json() + assert "featured_topics" in data + assert len(data["featured_topics"]) <= 3 + assert all("title" in item for item in data["featured_topics"]) + + +def test_post_page_featured_topics_pick_varies(app_server): + session = _signup() + post = _new_post(session) + for _ in range(6): + _seed_featured_article() + + seen = set() + for _ in range(20): + r = requests.get(f"{BASE_URL}/posts/{post['slug']}", headers=JSON) + assert r.status_code == 200 + titles = tuple(item["title"] for item in r.json()["featured_topics"]) + assert len(titles) == 3 + assert len(set(titles)) == 3 + seen.add(titles) + assert len(seen) > 1, "featured topics never varied across 20 requests" diff --git a/tests/api/posts/nextpost.py b/tests/api/posts/nextpost.py new file mode 100644 index 0000000..a33a091 --- /dev/null +++ b/tests/api/posts/nextpost.py @@ -0,0 +1,72 @@ +# retoor + +import time + +import requests + +from tests.conftest import BASE_URL + +JSON = {"Accept": "application/json"} +_counter = [0] + + +def _unique(prefix="nextpost"): + _counter[0] += 1 + return f"{prefix}{int(time.time() * 1000)}{_counter[0]}" + + +def _signup(): + name = _unique("nextuser") + session = requests.Session() + session.post( + f"{BASE_URL}/auth/signup", + data={ + "username": name, + "email": f"{name}@t.dev", + "password": "secret123", + "confirm_password": "secret123", + "birth_date": "1990-01-01", + "accept_terms": "1", + }, + allow_redirects=True, + ) + return session + + +def _new_post(session, title=None): + return session.post( + f"{BASE_URL}/posts/create", + headers=JSON, + data={ + "title": title or _unique("nextpostbody"), + "content": "content for the next-post navigation test", + "topic": "devlog", + }, + ).json()["data"] + + +def test_newer_post_links_to_the_next_older_post(app_server): + session = _signup() + older = _new_post(session, title="Older Post For Next Nav") + newer = _new_post(session, title="Newer Post For Next Nav") + + r = requests.get(f"{BASE_URL}/posts/{newer['slug']}", headers=JSON) + assert r.status_code == 200 + assert r.json()["next_post_url"] == f"/posts/{older['slug']}" + + html = requests.get(f"{BASE_URL}/posts/{newer['slug']}") + assert f'href="/posts/{older["slug"]}" class="back-link next-post-link"' in html.text + assert f'' in html.text + + +def test_next_post_link_is_absent_when_the_url_is_none(app_server): + session = _signup() + post = _new_post(session) + + data = requests.get(f"{BASE_URL}/posts/{post['slug']}", headers=JSON).json() + html = requests.get(f"{BASE_URL}/posts/{post['slug']}").text + if data["next_post_url"] is None: + assert "next-post-link" not in html + assert 'rel="next"' not in html + else: + assert f'href="{data["next_post_url"]}" class="back-link next-post-link"' in html diff --git a/tests/api/posts/sidebar.py b/tests/api/posts/sidebar.py new file mode 100644 index 0000000..acceb08 --- /dev/null +++ b/tests/api/posts/sidebar.py @@ -0,0 +1,138 @@ +# retoor + +import time + +import requests + +from tests.conftest import BASE_URL + +JSON = {"Accept": "application/json"} +_counter = [0] + + +def _unique(prefix="side"): + _counter[0] += 1 + return f"{prefix}{int(time.time() * 1000)}{_counter[0]}" + + +def _signup(): + name = _unique("sideuser") + session = requests.Session() + session.post( + f"{BASE_URL}/auth/signup", + data={ + "username": name, + "email": f"{name}@t.dev", + "password": "secret123", + "confirm_password": "secret123", + "birth_date": "1990-01-01", + "accept_terms": "1", + }, + allow_redirects=True, + ) + return session, name + + +def _new_post(session): + return session.post( + f"{BASE_URL}/posts/create", + headers=JSON, + data={ + "title": _unique("sidepost"), + "content": "content for the author-sidebar test post", + "topic": "devlog", + }, + ).json()["data"] + + +def _new_gist(session, title=None): + return session.post( + f"{BASE_URL}/gists/create", + headers=JSON, + data={ + "title": title or _unique("sidegist"), + "description": "a gist used for the author sidebar test", + "source_code": "print('hi')", + "language": "python", + }, + ).json()["data"] + + +def _new_project(session, title=None, is_private=None): + data = { + "title": title or _unique("sideproj"), + "description": "a project used for the author sidebar test", + "project_type": "software", + "status": "In Development", + "platforms": "", + } + created = session.post(f"{BASE_URL}/projects/create", headers=JSON, data=data).json()["data"] + if is_private: + session.post( + f"{BASE_URL}/projects/{created['slug']}/private", + headers=JSON, + data={"value": "true"}, + ) + return created + + +def test_post_page_has_no_author_cards_with_nothing_else(app_server): + session, name = _signup() + post = _new_post(session) + r = requests.get(f"{BASE_URL}/posts/{post['slug']}") + assert r.status_code == 200 + assert f"Gists from {name}" not in r.text + assert f"Projects from {name}" not in r.text + + +def test_post_page_shows_author_gists_and_projects(app_server): + session, name = _signup() + post = _new_post(session) + _new_gist(session, title="Alpha Gist") + _new_project(session, title="Alpha Project") + + r = requests.get(f"{BASE_URL}/posts/{post['slug']}") + assert r.status_code == 200 + assert f"Gists from {name}" in r.text + assert f"Projects from {name}" in r.text + assert "Alpha Gist" in r.text + assert "Alpha Project" in r.text + + +def test_post_page_sidebar_json_parity_and_limit(app_server): + session, name = _signup() + post = _new_post(session) + for i in range(7): + _new_gist(session, title=f"Gist {i}") + + r = requests.get(f"{BASE_URL}/posts/{post['slug']}", headers=JSON) + assert r.status_code == 200 + data = r.json() + assert len(data["author_gists"]) == 5 + assert data["author_projects"] == [] + + +def test_post_page_hides_private_project_from_stranger(app_server): + session, name = _signup() + post = _new_post(session) + _new_project(session, title="Hidden Project", is_private=True) + + stranger = requests.Session() + r = stranger.get(f"{BASE_URL}/posts/{post['slug']}", headers=JSON) + assert r.status_code == 200 + titles = [p["title"] for p in r.json()["author_projects"]] + assert "Hidden Project" not in titles + + +def test_post_page_sidebar_updates_live_after_new_gist(app_server): + session, name = _signup() + post = _new_post(session) + + r0 = requests.get(f"{BASE_URL}/posts/{post['slug']}", headers=JSON) + assert r0.json()["author_gists"] == [] + + _new_gist(session, title="Just Created Gist") + + r1 = requests.get(f"{BASE_URL}/posts/{post['slug']}", headers=JSON) + titles = [g["title"] for g in r1.json()["author_gists"]] + assert "Just Created Gist" in titles diff --git a/tests/api/profile/delete.py b/tests/api/profile/delete.py index c736bb3..33d783f 100644 --- a/tests/api/profile/delete.py +++ b/tests/api/profile/delete.py @@ -2,15 +2,25 @@ import time +import pytest import requests -from devplacepy.database import get_table, refresh_snapshot -from tests.conftest import BASE_URL +from devplacepy.database import get_table, refresh_snapshot, set_setting +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS JSON = {"Accept": "application/json"} _counter = [0] +@pytest.fixture(scope="module", autouse=True) +def _disable_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + + def _unique(prefix="del"): _counter[0] += 1 return f"{prefix}{int(time.time() * 1000)}{_counter[0]}" diff --git a/tests/api/profile/index.py b/tests/api/profile/index.py index 43b4ac5..3dd721f 100644 --- a/tests/api/profile/index.py +++ b/tests/api/profile/index.py @@ -1,7 +1,11 @@ # retoor +import time + +import pytest import requests -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import set_setting def _seed_news_seo(): from datetime import datetime, timezone from devplacepy.database import get_table @@ -166,7 +170,16 @@ def _seed_feed_posts(count): return topic -def test_missing_profile_returns_404(app_server): +@pytest.fixture() +def _disable_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + + +def test_missing_profile_returns_404(app_server, _disable_happy_404): r = requests.get(f"{BASE_URL}/profile/no-such-user-xyz", allow_redirects=False) assert r.status_code == 404 diff --git a/tests/api/proxy.py b/tests/api/proxy.py index d366510..e4e47b8 100644 --- a/tests/api/proxy.py +++ b/tests/api/proxy.py @@ -1,7 +1,20 @@ # retoor +import time + +import pytest import requests -from tests.conftest import BASE_URL +from devplacepy.database import set_setting +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS + + +@pytest.fixture(scope="module", autouse=True) +def _disable_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_proxy_unknown_slug_returns_404(app_server): diff --git a/tests/api/tools/deepsearch/export.py b/tests/api/tools/deepsearch/export.py index 24984bb..0711a0a 100644 --- a/tests/api/tools/deepsearch/export.py +++ b/tests/api/tools/deepsearch/export.py @@ -1,9 +1,10 @@ # retoor import json +import time import pytest import requests -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS from devplacepy.database import create_deepsearch_session, get_table, refresh_snapshot, set_setting from devplacepy.services.jobs import queue @@ -11,7 +12,11 @@ from devplacepy.services.jobs import queue @pytest.fixture(scope="module", autouse=True) def _settings(app_server): set_setting("rate_limit_per_minute", "1000000") + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _weasyprint_available() -> bool: diff --git a/tests/api/tools/deepsearch/session.py b/tests/api/tools/deepsearch/session.py index 4c02deb..7f1ab2e 100644 --- a/tests/api/tools/deepsearch/session.py +++ b/tests/api/tools/deepsearch/session.py @@ -1,17 +1,29 @@ # retoor import json +import time +import pytest import requests -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS from devplacepy.database import ( create_deepsearch_session, get_table, refresh_snapshot, + set_setting, ) from devplacepy.services.jobs import queue +@pytest.fixture +def _no_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + + def _json_headers(): return {"Accept": "application/json"} @@ -204,6 +216,6 @@ def test_export_json(app_server): _clear() -def test_export_unknown_uid_404(app_server): +def test_export_unknown_uid_404(app_server, _no_happy_404): r = requests.get(f"{BASE_URL}/tools/deepsearch/nope/export.md") assert r.status_code == 404 diff --git a/tests/api/tools/isslop.py b/tests/api/tools/isslop.py index 4bdee1a..66c7a3c 100644 --- a/tests/api/tools/isslop.py +++ b/tests/api/tools/isslop.py @@ -3,16 +3,26 @@ import time import uuid +import pytest import requests -from tests.conftest import BASE_URL -from devplacepy.database import get_table, refresh_snapshot +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, refresh_snapshot, set_setting from devplacepy.services.jobs.isslop import store from devplacepy.utils import generate_uid _counter_isslop = [0] +@pytest.fixture +def _no_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + + def _json_headers(): return {"Accept": "application/json"} @@ -132,7 +142,7 @@ def test_status_unknown_uid_404(app_server): assert r.status_code == 404 -def test_report_json_while_pending(app_server): +def test_report_json_while_pending(app_server, _no_happy_404): session = requests.Session() try: run = session.post( @@ -287,7 +297,7 @@ def test_media_route_serves_a_thumbnail(app_server): store.purge_analysis(uid) -def test_media_route_rejects_a_name_outside_the_hex_pattern(app_server): +def test_media_route_rejects_a_name_outside_the_hex_pattern(app_server, _no_happy_404): uid = generate_uid() store.create_analysis(uid, "https://github.com/owner/repository", "guest", "media-traversal-owner") try: diff --git a/tests/api/tools/seo.py b/tests/api/tools/seo.py index 5767671..ac1a903 100644 --- a/tests/api/tools/seo.py +++ b/tests/api/tools/seo.py @@ -1,11 +1,23 @@ # retoor +import time + +import pytest import requests -from tests.conftest import BASE_URL -from devplacepy.database import get_table, refresh_snapshot +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, refresh_snapshot, set_setting from devplacepy.services.jobs import queue +@pytest.fixture(scope="module", autouse=True) +def _disable_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + + def _json_headers(): return {"Accept": "application/json"} diff --git a/tests/api/topics.py b/tests/api/topics.py index 5b3187f..af7cf0a 100644 --- a/tests/api/topics.py +++ b/tests/api/topics.py @@ -2,9 +2,10 @@ import time from datetime import datetime, timedelta, timezone +import pytest import requests -from tests.conftest import BASE_URL -from devplacepy.database import get_table, refresh_snapshot +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, refresh_snapshot, set_setting from devplacepy.database.pagination import PAGE_SIZE from devplacepy.utils import generate_uid @@ -12,6 +13,15 @@ JSON_topics = {"Accept": "application/json"} _counter_topics = [0] +@pytest.fixture +def _no_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + + def _session_topics(): _counter_topics[0] += 1 name = f"tpc{int(time.time() * 1000)}{_counter_topics[0]}" @@ -107,7 +117,7 @@ def test_topic_page_lists_only_that_topics_posts(app_server): assert showcase_title not in titles -def test_topic_page_rejects_an_unknown_topic(app_server): +def test_topic_page_rejects_an_unknown_topic(app_server, _no_happy_404): r = requests.get(f"{BASE_URL}/topics/not-a-real-topic", allow_redirects=False) assert r.status_code == 404 diff --git a/tests/api/zips/download.py b/tests/api/zips/download.py index cebf022..c3d14e6 100644 --- a/tests/api/zips/download.py +++ b/tests/api/zips/download.py @@ -6,14 +6,24 @@ import json import time import zipfile from pathlib import Path +import pytest import requests from playwright.sync_api import expect -from tests.conftest import BASE_URL -from devplacepy.database import get_table, refresh_snapshot +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, refresh_snapshot, set_setting from devplacepy.services.jobs import queue from devplacepy.services.jobs.zip_service import ZipService from tests.conftest import run_async _counter_zip_download = [0] + + +@pytest.fixture(scope="module", autouse=True) +def _disable_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _signup_zip_download(): _counter_zip_download[0] += 1 name = f"zd{int(time.time() * 1000)}{_counter_zip_download[0]}" diff --git a/tests/conftest.py b/tests/conftest.py index 959f545..6c3a239 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,6 +39,9 @@ os.environ["DEVPLACE_SITEMAP_TTL"] = "0" os.environ["DEVPLACE_HOME_CACHE_TTL"] = "0" os.environ["DEVPLACE_RANKING_TTL"] = "0" os.environ["DEVPLACE_MARKET_SATURATION_TTL"] = "0" +os.environ["DEVPLACE_HAPPY_404_POOL_TTL"] = "0" +os.environ["DEVPLACE_FEATURED_TOPICS_POOL_TTL"] = "0" +os.environ["DEVPLACE_USER_RECENT_ITEMS_TTL"] = "0" _ASYNC_LOOP = None diff --git a/tests/e2e/admin/auditlog.py b/tests/e2e/admin/auditlog.py index 1245f70..8a9f5e1 100644 --- a/tests/e2e/admin/auditlog.py +++ b/tests/e2e/admin/auditlog.py @@ -2,7 +2,7 @@ from uuid import uuid4 from datetime import datetime, timezone -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS from devplacepy.database import get_table from devplacepy.utils import make_combined_slug def seed_admin_news(count=3): @@ -265,10 +265,16 @@ def test_audit_log_view_detail(alice): def test_audit_log_detail_unknown_404(alice): page, _ = alice - resp = page.goto( - f"{BASE_URL}/admin/audit-log/nonexistent", wait_until="domcontentloaded" - ) - assert resp.status == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + resp = page.goto( + f"{BASE_URL}/admin/audit-log/nonexistent", wait_until="domcontentloaded" + ) + assert resp.status == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_audit_log_guest_redirects_to_login(page, app_server): diff --git a/tests/e2e/admin/containers/index.py b/tests/e2e/admin/containers/index.py index 6fe3cdd..43f46ae 100644 --- a/tests/e2e/admin/containers/index.py +++ b/tests/e2e/admin/containers/index.py @@ -1,9 +1,11 @@ # retoor +import time + import requests from playwright.sync_api import expect -from tests.conftest import BASE_URL -from devplacepy.database import get_table +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, set_setting USER_INPUT = '#cm-admin-create-form input[data-search="user"]' USER_OPTIONS = '#cm-admin-create-form [data-suggest="user"] li[data-value]' @@ -29,8 +31,14 @@ def test_containers_admin_page_loads(alice, app_server): def test_container_instance_page_404_for_missing(alice, app_server): page, _ = alice - page.goto(f"{BASE_URL}/admin/containers/nonexistent", wait_until="domcontentloaded") - assert page.is_visible("text=Not Found") or page.is_visible("text=404") + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + page.goto(f"{BASE_URL}/admin/containers/nonexistent", wait_until="domcontentloaded") + assert page.is_visible("text=Not Found") or page.is_visible("text=404") + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_user_search_arrow_keys_highlight_and_enter_selects(alice, seeded_db): diff --git a/tests/e2e/admin/containers/manage.py b/tests/e2e/admin/containers/manage.py index 2a201f4..c4f402f 100644 --- a/tests/e2e/admin/containers/manage.py +++ b/tests/e2e/admin/containers/manage.py @@ -2,12 +2,13 @@ import time import requests -from tests.conftest import BASE_URL, login_user +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS, login_user from devplacepy.database import ( get_table, get_primary_admin_uid, invalidate_admins_cache, refresh_snapshot, + set_setting, ) from devplacepy.utils import clear_user_cache @@ -201,11 +202,15 @@ def test_admin_detail_page_404_for_non_viewer_on_private_project(page, app_serve other = _make_admin() project = _create_project(owner["api_key"], "E2E Manage Detail Private", is_private=True) inst_uid = _insert_instance(project["uid"], owner["uid"]) + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) try: login_user(page, other) page.goto(f"{BASE_URL}/admin/containers/{inst_uid}", wait_until="domcontentloaded") assert page.is_visible("text=Not Found") or page.is_visible("text=404") finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) _cleanup(inst_uid) diff --git a/tests/e2e/admin/services/data.py b/tests/e2e/admin/services/data.py index 5ddddaf..5be072b 100644 --- a/tests/e2e/admin/services/data.py +++ b/tests/e2e/admin/services/data.py @@ -1,7 +1,9 @@ # retoor -from tests.conftest import BASE_URL, login_user -from devplacepy.database import get_table +import time + +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS, login_user +from devplacepy.database import get_table, set_setting def _promote_to_admin(username: str) -> None: users = get_table("users") user = users.find_one(username=username) @@ -26,9 +28,15 @@ def test_service_detail_unknown_404(page, seeded_db): user = seeded_db["alice"] _promote_to_admin(user["username"]) login_user(page, user) - resp = page.request.get(f"{BASE_URL}/admin/services/nope") - assert resp.status == 404 - assert page.request.get(f"{BASE_URL}/admin/services/nope/data").status == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + resp = page.request.get(f"{BASE_URL}/admin/services/nope") + assert resp.status == 404 + assert page.request.get(f"{BASE_URL}/admin/services/nope/data").status == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_bots_service_registered_and_opt_in(page, seeded_db): diff --git a/tests/e2e/docs.py b/tests/e2e/docs.py index 6cca55a..da32fd8 100644 --- a/tests/e2e/docs.py +++ b/tests/e2e/docs.py @@ -3,11 +3,22 @@ import html import json import re +import time +import pytest import requests -from tests.conftest import BASE_URL -from devplacepy.database import get_table +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, set_setting from devplacepy.docs_api import API_GROUPS from devplacepy.utils import clear_user_cache + + +@pytest.fixture(scope="module", autouse=True) +def _disable_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _configs_by_id(text): configs = re.findall(r"data-config='(.*?)'", text, re.S) return { diff --git a/tests/e2e/feed.py b/tests/e2e/feed.py index f8860e4..83c1ddf 100644 --- a/tests/e2e/feed.py +++ b/tests/e2e/feed.py @@ -1,6 +1,6 @@ # retoor -from tests.conftest import BASE_URL, create_post_with_files, paste_image +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS, create_post_with_files, paste_image import time import requests from playwright.sync_api import expect @@ -11,6 +11,7 @@ from devplacepy.database import ( list_custom_overrides, set_custom_override, set_customization_pref, + set_setting, ) from devplacepy.utils import clear_user_cache def _user_customization_toggle(username): @@ -851,8 +852,14 @@ def test_delete_own_post(alice): page.locator(".post-action-btn:has-text('Delete')").click() page.locator(".dialog-overlay.visible .dialog-confirm").click() page.wait_for_url(f"{BASE_URL}/feed", wait_until="domcontentloaded") - resp = page.goto(post_url, wait_until="domcontentloaded") - assert resp.status == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + resp = page.goto(post_url, wait_until="domcontentloaded") + assert resp.status == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_profile_nav_from_topbar(alice): diff --git a/tests/e2e/forks.py b/tests/e2e/forks.py index 80f9a2d..3dd6c51 100644 --- a/tests/e2e/forks.py +++ b/tests/e2e/forks.py @@ -1,9 +1,20 @@ # retoor import json -from tests.conftest import BASE_URL -from devplacepy.database import get_table +import time +import pytest +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import get_table, set_setting from devplacepy.services.jobs import queue + + +@pytest.fixture(scope="module", autouse=True) +def _disable_happy_404(app_server): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + yield + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _seed_job(uid="test-fork-uid-001"): jobs = get_table("jobs") jobs.upsert( diff --git a/tests/e2e/game/farm.py b/tests/e2e/game/farm.py index 3dae899..2f5ba95 100644 --- a/tests/e2e/game/farm.py +++ b/tests/e2e/game/farm.py @@ -1,11 +1,13 @@ # retoor +import time from datetime import datetime, timedelta, timezone from playwright.sync_api import expect -from tests.conftest import BASE_URL +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS from tests.e2e.game.index import reset_farm, warp_ready +from devplacepy.database import set_setting def _plant_growing(username, crop="python", slot=0): @@ -84,10 +86,16 @@ def test_guest_can_view_farm(page): def test_unknown_farm_is_404(bob): page, _ = bob - response = page.goto( - f"{BASE_URL}/game/farm/nope_nobody", wait_until="domcontentloaded" - ) - assert response is not None and response.status == 404 + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + response = page.goto( + f"{BASE_URL}/game/farm/nope_nobody", wait_until="domcontentloaded" + ) + assert response is not None and response.status == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_ready_build_is_protected_during_grace(bob): diff --git a/tests/e2e/issues/index.py b/tests/e2e/issues/index.py index 241d0d2..fc0316e 100644 --- a/tests/e2e/issues/index.py +++ b/tests/e2e/issues/index.py @@ -1,6 +1,9 @@ # retoor -from tests.conftest import BASE_URL +import time + +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import set_setting def test_issues_page_loads(alice): @@ -61,6 +64,12 @@ def test_issue_button_icon_spacing(alice): def test_issue_detail_not_configured(alice): page, _ = alice - page.goto(f"{BASE_URL}/issues/999", wait_until="domcontentloaded") - assert page.is_visible("h1.error-code:has-text('404')") - assert page.is_visible("text=Page not found") + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + page.goto(f"{BASE_URL}/issues/999", wait_until="domcontentloaded") + assert page.is_visible("h1.error-code:has-text('404')") + assert page.is_visible("text=Page not found") + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) diff --git a/tests/e2e/issues/jobs.py b/tests/e2e/issues/jobs.py index 36bad78..fec2127 100644 --- a/tests/e2e/issues/jobs.py +++ b/tests/e2e/issues/jobs.py @@ -1,10 +1,19 @@ # retoor -from tests.conftest import BASE_URL +import time + +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS +from devplacepy.database import set_setting def test_issue_job_status_not_found(alice): page, _ = alice - page.goto(f"{BASE_URL}/issues/jobs/nonexistent-job", wait_until="domcontentloaded") - assert page.is_visible("h1.error-code:has-text('404')") - assert page.is_visible("text=Page not found") + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + page.goto(f"{BASE_URL}/issues/jobs/nonexistent-job", wait_until="domcontentloaded") + assert page.is_visible("h1.error-code:has-text('404')") + assert page.is_visible("text=Page not found") + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) diff --git a/tests/e2e/news.py b/tests/e2e/news.py index 358793c..ff5055a 100644 --- a/tests/e2e/news.py +++ b/tests/e2e/news.py @@ -1,10 +1,12 @@ # retoor +import time + import pytest from uuid import uuid4 from datetime import datetime, timedelta, timezone -from tests.conftest import BASE_URL, assert_share_copies -from devplacepy.database import get_table +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS, assert_share_copies +from devplacepy.database import get_table, set_setting from devplacepy.utils import make_combined_slug def _seed_news_paginated(count): news_table = get_table("news") @@ -258,8 +260,14 @@ def test_news_detail_loads(page, news_article): def test_news_detail_404(page, app_server): - page.goto(f"{BASE_URL}/news/nonexistent-article", wait_until="domcontentloaded") - assert page.is_visible("text=not found", timeout=5000) + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + page.goto(f"{BASE_URL}/news/nonexistent-article", wait_until="domcontentloaded") + assert page.is_visible("text=not found", timeout=5000) + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_news_comment(alice, news_article): diff --git a/tests/e2e/projects/index.py b/tests/e2e/projects/index.py index 40292d6..5043adc 100644 --- a/tests/e2e/projects/index.py +++ b/tests/e2e/projects/index.py @@ -4,9 +4,20 @@ import re from uuid import uuid4 from datetime import datetime, timedelta, timezone from playwright.sync_api import expect -from tests.conftest import BASE_URL, assert_share_copies -from devplacepy.database import get_table +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS, assert_share_copies +from devplacepy.database import get_table, set_setting from devplacepy.utils import make_combined_slug + + +def _goto_expect_404(page, url): + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + resp = page.goto(url, wait_until="domcontentloaded") + assert resp.status == 404 + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def _seed_posts(count): suite = uuid4().hex[:8] topic = f"pag{suite}" @@ -496,8 +507,7 @@ def test_delete_own_project(alice): page.locator(".context-menu-item:has-text('Delete')").click() page.locator(".dialog-overlay.visible .dialog-confirm").click() page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded") - resp = page.goto(proj_url, wait_until="domcontentloaded") - assert resp.status == 404 + _goto_expect_404(page, proj_url) def test_project_edit_button(alice): @@ -850,8 +860,7 @@ def test_data_confirm_accept_proceeds(alice): _click_delete(page) page.locator(".dialog-overlay.visible .dialog-confirm").click() page.wait_for_url(f"{BASE_URL}/projects", wait_until="domcontentloaded") - resp = page.goto(proj_url, wait_until="domcontentloaded") - assert resp.status == 404 + _goto_expect_404(page, proj_url) def test_detail_download_button_downloads(app_server, page): diff --git a/tests/e2e/reports/ui.py b/tests/e2e/reports/ui.py index 481a299..704331a 100644 --- a/tests/e2e/reports/ui.py +++ b/tests/e2e/reports/ui.py @@ -1,9 +1,11 @@ # retoor +import time + from playwright.sync_api import expect -from devplacepy.database import get_table, refresh_snapshot -from tests.conftest import BASE_URL +from devplacepy.database import get_table, refresh_snapshot, set_setting +from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS def _create_post(page, content): @@ -124,10 +126,16 @@ def test_the_legal_pages_render_for_a_guest(page, app_server): def test_the_admin_operations_page_is_hidden_from_a_guest(page, app_server): - page.goto( - f"{BASE_URL}/docs/moderation-operations.html", wait_until="domcontentloaded" - ) - assert "not found" in page.content().lower() + set_setting("happy_404_enabled", "0") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) + try: + page.goto( + f"{BASE_URL}/docs/moderation-operations.html", wait_until="domcontentloaded" + ) + assert "not found" in page.content().lower() + finally: + set_setting("happy_404_enabled", "1") + time.sleep(CACHE_VERSION_PROPAGATION_SECONDS) def test_the_admin_operations_page_renders_for_an_admin(alice):