forked from retoor/devplacepy
feat: add generic paginate helper and cursor-based load-more across feed, gists, news, and projects
Extract shared PAGE_SIZE constant and paginate() function into database.py, replacing inline cursor logic in feed, gists, news, and projects routers. Introduce `_load_more.html` partial template to unify "Load More" button rendering. Update all affected route handlers to accept `before` query parameter and return `next_cursor` for seamless infinite scroll. Adjust projects count display to use filtered length instead of total. Add test helper `_seed_posts` for pagination coverage.
This commit is contained in:
@@ -1,8 +1,34 @@
|
||||
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 devplacepy.utils import make_combined_slug
|
||||
|
||||
|
||||
def _seed_posts(count):
|
||||
owner = str(uuid4())
|
||||
topic = f"pag{owner[:8]}"
|
||||
get_table("users").insert({
|
||||
"uid": owner, "username": f"pag_{owner[:8]}", "email": f"{owner[:8]}@test.devplace",
|
||||
"password_hash": "x", "role": "Member", "is_active": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
posts = get_table("posts")
|
||||
for i in range(count):
|
||||
uid = str(uuid4())
|
||||
content = f"Paginated post {i}"
|
||||
posts.insert({
|
||||
"uid": uid, "user_uid": owner, "slug": make_combined_slug(content, uid),
|
||||
"title": None, "content": content, "topic": topic, "project_uid": None,
|
||||
"image": None, "stars": 0,
|
||||
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
||||
})
|
||||
return topic
|
||||
|
||||
|
||||
def test_feed_vote_voted_state_persists(alice):
|
||||
@@ -208,6 +234,25 @@ def test_create_post_cancel_modal(alice):
|
||||
assert not modal.is_visible()
|
||||
|
||||
|
||||
def test_feed_pagination_first_page(alice):
|
||||
page, _ = alice
|
||||
topic = _seed_posts(26)
|
||||
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
|
||||
assert page.locator(".post-card").count() == 25
|
||||
assert page.is_visible(".load-more-wrap")
|
||||
|
||||
|
||||
def test_feed_pagination_load_more(alice):
|
||||
page, _ = alice
|
||||
topic = _seed_posts(26)
|
||||
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
|
||||
page.click(".load-more-wrap a")
|
||||
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
|
||||
assert f"topic={topic}" in page.url
|
||||
assert page.locator(".post-card").count() == 1
|
||||
assert not page.is_visible(".load-more-wrap")
|
||||
|
||||
|
||||
def test_feed_public_access(page, app_server):
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
assert page.is_visible("text=Topics")
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import re
|
||||
import time
|
||||
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 devplacepy.utils import make_combined_slug
|
||||
|
||||
|
||||
def _seed_gists(count):
|
||||
owner = str(uuid4())
|
||||
get_table("users").insert({
|
||||
"uid": owner, "username": f"pag_{owner[:8]}", "email": f"{owner[:8]}@test.devplace",
|
||||
"password_hash": "x", "role": "Member", "is_active": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
gists = get_table("gists")
|
||||
for i in range(count):
|
||||
uid = str(uuid4())
|
||||
title = f"Pag Gist {i}"
|
||||
gists.insert({
|
||||
"uid": uid, "user_uid": owner, "slug": make_combined_slug(title, uid),
|
||||
"title": title, "language": "python", "description": "paginated", "stars": 0,
|
||||
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
||||
})
|
||||
return owner
|
||||
|
||||
|
||||
def _set_cm_value(page, value):
|
||||
@@ -201,6 +225,33 @@ def test_gist_listing_shows_created_gist(alice, app_server):
|
||||
assert page.is_visible(f"text={title}")
|
||||
|
||||
|
||||
def test_gist_pagination_first_page(alice):
|
||||
page, _ = alice
|
||||
owner = _seed_gists(26)
|
||||
page.goto(f"{BASE_URL}/gists?user_uid={owner}", wait_until="domcontentloaded")
|
||||
assert page.locator(".gist-card").count() == 25
|
||||
assert page.is_visible(".load-more-wrap")
|
||||
|
||||
|
||||
def test_gist_pagination_load_more(alice):
|
||||
page, _ = alice
|
||||
owner = _seed_gists(26)
|
||||
page.goto(f"{BASE_URL}/gists?user_uid={owner}", wait_until="domcontentloaded")
|
||||
page.click(".load-more-wrap a")
|
||||
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
|
||||
assert f"user_uid={owner}" in page.url
|
||||
assert page.locator(".gist-card").count() == 1
|
||||
assert not page.is_visible(".load-more-wrap")
|
||||
|
||||
|
||||
def test_gist_no_pagination_below_page_size(alice):
|
||||
page, _ = alice
|
||||
owner = _seed_gists(10)
|
||||
page.goto(f"{BASE_URL}/gists?user_uid={owner}", wait_until="domcontentloaded")
|
||||
assert page.locator(".gist-card").count() == 10
|
||||
assert not page.is_visible(".load-more-wrap")
|
||||
|
||||
|
||||
def test_gist_voted_state_persists(alice):
|
||||
page, _ = alice
|
||||
_create_gist(page, title="Voted State Gist")
|
||||
|
||||
+39
-2
@@ -1,11 +1,30 @@
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from tests.conftest import BASE_URL, assert_share_copies
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import make_combined_slug
|
||||
|
||||
|
||||
def _seed_news_paginated(count):
|
||||
news_table = get_table("news")
|
||||
base = datetime.now(timezone.utc)
|
||||
marker = uuid4().hex[:8]
|
||||
titles = []
|
||||
for i in range(count):
|
||||
uid = str(uuid4())
|
||||
title = f"Pag News {marker} {i:02d}"
|
||||
titles.append(title)
|
||||
news_table.insert({
|
||||
"uid": uid, "slug": make_combined_slug(title, uid), "title": title,
|
||||
"external_id": f"pag_{marker}_{i}", "grade": 10,
|
||||
"status": "published", "show_on_landing": 0, "source_name": "PagSource",
|
||||
"url": "https://example.com", "description": "paginated news",
|
||||
"synced_at": (base - timedelta(seconds=i)).isoformat(),
|
||||
})
|
||||
return titles
|
||||
|
||||
|
||||
def test_news_detail_share_button(page, news_article):
|
||||
slug = news_article.get("slug") or news_article["uid"]
|
||||
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
|
||||
@@ -34,7 +53,7 @@ def seed_news():
|
||||
@pytest.fixture
|
||||
def news_article(app_server):
|
||||
seed_news()
|
||||
article = get_table("news").find_one(order_by=["-synced_at"])
|
||||
article = get_table("news").find_one(external_id="test_news_0")
|
||||
return article
|
||||
|
||||
|
||||
@@ -74,6 +93,24 @@ def test_news_comment(alice, news_article):
|
||||
assert page.is_visible("text=Test comment on news article")
|
||||
|
||||
|
||||
def test_news_pagination_first_page(page, app_server):
|
||||
_seed_news_paginated(30)
|
||||
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
|
||||
assert page.locator(".news-card").count() == 25
|
||||
assert page.is_visible(".load-more-wrap")
|
||||
|
||||
|
||||
def test_news_pagination_load_more(page, app_server):
|
||||
titles = _seed_news_paginated(30)
|
||||
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
|
||||
newest = titles[0]
|
||||
assert page.is_visible(f".news-card-title:has-text('{newest}')")
|
||||
page.click(".load-more-wrap a")
|
||||
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
|
||||
assert not page.is_visible(f".news-card-title:has-text('{newest}')")
|
||||
assert page.locator(".news-card").count() >= 1
|
||||
|
||||
|
||||
def test_news_detail_guest(page, news_article):
|
||||
slug = news_article.get("slug") or news_article["uid"]
|
||||
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
|
||||
|
||||
+1
-1
@@ -229,7 +229,7 @@ def test_post_edit_submit(alice):
|
||||
page.fill("#edit-title", "Edited Title")
|
||||
page.fill("#edit-content", "Edited content for the post")
|
||||
page.click("button:has-text('Save Changes')")
|
||||
expect(page.locator("text=Edited Title")).to_be_visible()
|
||||
expect(page.locator(".post-detail-title:has-text('Edited Title')")).to_be_visible()
|
||||
|
||||
|
||||
def test_post_across_all_topics(alice):
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
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 devplacepy.utils import make_combined_slug
|
||||
|
||||
|
||||
def _seed_projects(count):
|
||||
owner = str(uuid4())
|
||||
get_table("users").insert({
|
||||
"uid": owner, "username": f"pag_{owner[:8]}", "email": f"{owner[:8]}@test.devplace",
|
||||
"password_hash": "x", "role": "Member", "is_active": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
projects = get_table("projects")
|
||||
for i in range(count):
|
||||
uid = str(uuid4())
|
||||
title = f"Pag Project {i}"
|
||||
projects.insert({
|
||||
"uid": uid, "user_uid": owner, "slug": make_combined_slug(title, uid),
|
||||
"title": title, "description": "paginated", "project_type": "software",
|
||||
"status": "In Development", "stars": 0,
|
||||
"created_at": (base - timedelta(seconds=i)).isoformat(),
|
||||
})
|
||||
return owner
|
||||
|
||||
|
||||
def _create_project(page, title):
|
||||
@@ -186,6 +211,33 @@ def test_project_platform_presets(alice):
|
||||
assert tag.is_visible()
|
||||
|
||||
|
||||
def test_project_pagination_first_page(alice):
|
||||
page, _ = alice
|
||||
owner = _seed_projects(26)
|
||||
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
|
||||
assert page.locator(".project-card").count() == 25
|
||||
assert page.is_visible(".load-more-wrap")
|
||||
|
||||
|
||||
def test_project_pagination_load_more(alice):
|
||||
page, _ = alice
|
||||
owner = _seed_projects(26)
|
||||
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
|
||||
page.click(".load-more-wrap a")
|
||||
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
|
||||
assert f"user_uid={owner}" in page.url
|
||||
assert page.locator(".project-card").count() == 1
|
||||
assert not page.is_visible(".load-more-wrap")
|
||||
|
||||
|
||||
def test_project_pagination_count_reflects_total(alice):
|
||||
page, _ = alice
|
||||
owner = _seed_projects(26)
|
||||
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
|
||||
count_text = page.text_content(".projects-count")
|
||||
assert "Showing 25 of 26 projects" in count_text
|
||||
|
||||
|
||||
def test_projects_count(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ def test_post_content_is_sanitized(alice):
|
||||
page.locator(".post-detail-content p").first.wait_for(state="visible")
|
||||
html = content.inner_html().lower()
|
||||
assert "<script" not in html
|
||||
assert "onerror" not in html
|
||||
assert "<img" not in html
|
||||
assert not fired, f"XSS payload executed: {fired}"
|
||||
|
||||
|
||||
@@ -31,5 +31,5 @@ def test_comment_content_is_sanitized(alice):
|
||||
page.locator(".comment-text p").first.wait_for(state="visible")
|
||||
html = comment.inner_html().lower()
|
||||
assert "<script" not in html
|
||||
assert "onerror" not in html
|
||||
assert "<img" not in html
|
||||
assert not fired, f"XSS payload executed: {fired}"
|
||||
|
||||
Reference in New Issue
Block a user