This commit is contained in:
parent
c826096843
commit
c6c0ec6c39
@ -528,6 +528,21 @@ def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
|
||||
return row["user_uid"] if row else None
|
||||
|
||||
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
def paginate(table, *clauses, before=None, order=None, cursor_field="created_at", **filters):
|
||||
order = order or ["-" + cursor_field]
|
||||
clauses = list(clauses)
|
||||
if before:
|
||||
clauses.append(table.table.columns[cursor_field] < before)
|
||||
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
|
||||
has_more = len(rows) > PAGE_SIZE
|
||||
rows = rows[:PAGE_SIZE]
|
||||
next_cursor = rows[-1][cursor_field] if has_more and rows else None
|
||||
return rows, next_cursor
|
||||
|
||||
|
||||
def build_pagination(page, total, per_page=25):
|
||||
total_pages = max(1, __import__("math").ceil(total / per_page))
|
||||
page = max(1, min(page, total_pages))
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats, get_top_authors
|
||||
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats, get_top_authors, paginate
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.templating import templates
|
||||
@ -11,8 +11,6 @@ from devplacepy.seo import list_page_seo
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None):
|
||||
posts_table = get_table("posts")
|
||||
@ -25,22 +23,10 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
|
||||
following = [f["following_uid"] for f in follows.find(follower_uid=user["uid"])]
|
||||
if not following:
|
||||
return [], None
|
||||
clauses = [posts_table.table.columns.user_uid.in_(following)]
|
||||
if before:
|
||||
clauses.append(posts_table.table.columns.created_at < before)
|
||||
posts = list(posts_table.find(*clauses, order_by=order, _limit=PAGE_SIZE + 1))
|
||||
posts, next_cursor = paginate(posts_table, posts_table.table.columns.user_uid.in_(following), before=before, order=order)
|
||||
else:
|
||||
filters = {}
|
||||
if topic:
|
||||
filters["topic"] = topic
|
||||
if before:
|
||||
filters["created_at"] = {"<": before}
|
||||
posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1))
|
||||
|
||||
has_more = len(posts) > PAGE_SIZE
|
||||
posts = posts[:PAGE_SIZE]
|
||||
|
||||
next_cursor = posts[-1]["created_at"] if has_more and posts else None
|
||||
filters = {"topic": topic} if topic else {}
|
||||
posts, next_cursor = paginate(posts_table, before=before, order=order, **filters)
|
||||
|
||||
if not posts:
|
||||
return [], next_cursor
|
||||
|
||||
@ -3,7 +3,7 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import GistForm, GistEditForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages, paginate
|
||||
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST
|
||||
@ -23,7 +23,7 @@ LANGUAGES = [
|
||||
]
|
||||
|
||||
|
||||
def get_gists_list(user_uid=None, language=None, viewer=None):
|
||||
def get_gists_list(user_uid=None, language=None, before=None, viewer=None):
|
||||
gists_table = get_table("gists")
|
||||
filters = {}
|
||||
if user_uid:
|
||||
@ -31,20 +31,20 @@ def get_gists_list(user_uid=None, language=None, viewer=None):
|
||||
if language:
|
||||
filters["language"] = language
|
||||
|
||||
all_gists = list(gists_table.find(**filters, order_by=["-created_at"]))
|
||||
total = gists_table.count(**filters)
|
||||
gists, next_cursor = paginate(gists_table, before=before, **filters)
|
||||
|
||||
if not all_gists:
|
||||
return []
|
||||
if not gists:
|
||||
return [], next_cursor, total
|
||||
|
||||
users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
|
||||
return enrich_items(all_gists, "gist", users_map, user=viewer)
|
||||
users_map = get_users_by_uids([g["user_uid"] for g in gists])
|
||||
return enrich_items(gists, "gist", users_map, user=viewer), next_cursor, total
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def gists_page(request: Request, language: str = None, user_uid: str = None):
|
||||
async def gists_page(request: Request, language: str = None, user_uid: str = None, before: str = None):
|
||||
user = get_current_user(request)
|
||||
gists_data = get_gists_list(user_uid, language, viewer=user)
|
||||
total_count = len(gists_data)
|
||||
gists_data, next_cursor, total_count = get_gists_list(user_uid, language, before, viewer=user)
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
title="Gists",
|
||||
@ -60,6 +60,7 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
|
||||
"user": user,
|
||||
"gists": gists_data,
|
||||
"total_count": total_count,
|
||||
"next_cursor": next_cursor,
|
||||
"current_language": language,
|
||||
"languages": LANGUAGES,
|
||||
"gist_language_codes": get_gist_languages(),
|
||||
|
||||
@ -2,7 +2,7 @@ import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
|
||||
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, paginate
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, time_ago, not_found
|
||||
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo
|
||||
@ -14,16 +14,19 @@ NEWS_MAX_AGE_DAYS = 4
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def news_page(request: Request):
|
||||
async def news_page(request: Request, before: str = None):
|
||||
user = get_current_user(request)
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=NEWS_MAX_AGE_DAYS)).isoformat()
|
||||
|
||||
news_table = get_table("news")
|
||||
articles = list(news_table.find(
|
||||
articles, next_cursor = paginate(
|
||||
news_table,
|
||||
before=before,
|
||||
order=["-grade", "-synced_at"],
|
||||
cursor_field="synced_at",
|
||||
status="published",
|
||||
synced_at={">=": cutoff},
|
||||
order_by=["-grade", "-synced_at"],
|
||||
))
|
||||
)
|
||||
|
||||
article_uids = [a["uid"] for a in articles]
|
||||
images_by_news = get_news_images_by_uids(article_uids)
|
||||
@ -49,6 +52,7 @@ async def news_page(request: Request):
|
||||
"request": request,
|
||||
"user": user,
|
||||
"articles": enriched,
|
||||
"next_cursor": next_cursor,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ from sqlalchemy import or_
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import ProjectForm
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes
|
||||
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate
|
||||
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT
|
||||
@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None, viewer: dict = None):
|
||||
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None, before: str = None, viewer: dict = None):
|
||||
projects = get_table("projects")
|
||||
|
||||
filters = {}
|
||||
@ -31,17 +31,18 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non
|
||||
clauses.append(or_(projects.table.columns.title.ilike(like), projects.table.columns.description.ilike(like)))
|
||||
|
||||
order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
|
||||
all_projects = list(projects.find(*clauses, **filters, order_by=order))
|
||||
total = projects.count(*clauses, **filters)
|
||||
page, next_cursor = paginate(projects, *clauses, before=before, order=order, **filters)
|
||||
|
||||
if all_projects:
|
||||
users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
|
||||
my_votes = get_user_votes(viewer["uid"], [p["uid"] for p in all_projects]) if viewer else {}
|
||||
for p in all_projects:
|
||||
if page:
|
||||
users_map = get_users_by_uids([p["user_uid"] for p in page])
|
||||
my_votes = get_user_votes(viewer["uid"], [p["uid"] for p in page]) if viewer else {}
|
||||
for p in page:
|
||||
author = users_map.get(p["user_uid"])
|
||||
p["author_name"] = author["username"] if author else "Unknown"
|
||||
p["my_vote"] = my_votes.get(p["uid"], 0)
|
||||
|
||||
return all_projects
|
||||
return page, next_cursor, total
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
@ -51,15 +52,16 @@ async def projects_page(
|
||||
search: str = "",
|
||||
user_uid: str = None,
|
||||
project_type: str = None,
|
||||
before: str = None,
|
||||
):
|
||||
user = get_current_user(request)
|
||||
projects = get_projects_list(tab, search, user_uid, project_type, viewer=user)
|
||||
projects, next_cursor, total_count = get_projects_list(tab, search, user_uid, project_type, before, viewer=user)
|
||||
total_members = get_site_stats()["total_members"]
|
||||
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
title="Projects",
|
||||
description=f"Explore {len(projects)} developer projects on DevPlace. Games, software, mobile apps and more.",
|
||||
description=f"Explore {total_count} developer projects on DevPlace. Games, software, mobile apps and more.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Projects", "url": "/projects"},
|
||||
@ -73,7 +75,8 @@ async def projects_page(
|
||||
"current_tab": tab,
|
||||
"search": search,
|
||||
"project_type": project_type,
|
||||
"total_count": len(projects),
|
||||
"total_count": total_count,
|
||||
"next_cursor": next_cursor,
|
||||
"total_members": total_members,
|
||||
})
|
||||
|
||||
|
||||
5
devplacepy/templates/_load_more.html
Normal file
5
devplacepy/templates/_load_more.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% if next_cursor %}
|
||||
<div class="load-more-wrap">
|
||||
<a href="{{ request.url.path }}?{{ request.url.include_query_params(before=next_cursor).query }}" class="btn btn-secondary btn-sm"><span class="icon">📄</span>Load More</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -61,11 +61,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if next_cursor %}
|
||||
<div class="load-more-wrap">
|
||||
<a href="/feed?before={{ next_cursor }}{% if current_tab %}&tab={{ current_tab }}{% endif %}{% if current_topic %}&topic={{ current_topic }}{% endif %}" class="btn btn-secondary btn-sm"><span class="icon">📄</span>Load More</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "_load_more.html" %}
|
||||
</div>
|
||||
|
||||
<aside class="feed-right">
|
||||
|
||||
@ -79,6 +79,8 @@
|
||||
<div class="empty-state empty-state-full">No gists found. Create one!</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "_load_more.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -43,6 +43,8 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "_load_more.html" %}
|
||||
{% else %}
|
||||
<div class="news-empty">
|
||||
<div class="news-empty-icon">📰</div>
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
<h1>Projects</h1>
|
||||
<span class="subtitle">Discover amazing projects from the DevPlace community</span>
|
||||
</div>
|
||||
<div class="projects-count">Showing {{ total_count }} of {{ total_count }} projects</div>
|
||||
<div class="projects-count">Showing {{ projects|length }} of {{ total_count }} projects</div>
|
||||
</div>
|
||||
|
||||
<div class="projects-tabs">
|
||||
@ -90,6 +90,8 @@
|
||||
<div class="empty-state empty-state-full">No projects found. Create one!</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "_load_more.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user