fc3e: PostFeedService — cursor-paginated unified posts feed mirroring GalleryService.scroll
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
"""FC-3e: cursor-paginated read service for the Posts stream.
|
||||||
|
|
||||||
|
Mirrors GalleryService.scroll's cursor encoding so the frontend pattern
|
||||||
|
is identical: base64 of "<iso8601_sort_key>|<post_id>". Sort key is
|
||||||
|
COALESCE(Post.post_date, Post.downloaded_at) so posts without a
|
||||||
|
publish date sort by when we captured them.
|
||||||
|
|
||||||
|
Pure read-surface; no writes. The service composes the post dict
|
||||||
|
(including thumbnails from ImageRecord.primary_post_id and non-media
|
||||||
|
attachments from PostAttachment) so the API layer can jsonify directly.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..models import Artist, ImageRecord, Post, PostAttachment, Source
|
||||||
|
from ..utils.text import html_to_plain, truncate_at_word
|
||||||
|
from .gallery_service import thumbnail_url
|
||||||
|
|
||||||
|
CURSOR_SEPARATOR = "|"
|
||||||
|
DESCRIPTION_LIMIT = 280
|
||||||
|
THUMBNAIL_LIMIT = 6
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cursor(sort_key: datetime, post_id: int) -> str:
|
||||||
|
raw = f"{sort_key.isoformat()}{CURSOR_SEPARATOR}{post_id}"
|
||||||
|
return base64.urlsafe_b64encode(raw.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||||
|
try:
|
||||||
|
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
||||||
|
ts_part, id_part = raw.split(CURSOR_SEPARATOR, 1)
|
||||||
|
return datetime.fromisoformat(ts_part), int(id_part)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"invalid cursor: {cursor!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key():
|
||||||
|
"""Postgres COALESCE expression used in ORDER BY and WHERE clauses."""
|
||||||
|
return func.coalesce(Post.post_date, Post.downloaded_at)
|
||||||
|
|
||||||
|
|
||||||
|
class PostFeedService:
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def scroll(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cursor: str | None = None,
|
||||||
|
artist_id: int | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
limit: int = 24,
|
||||||
|
) -> dict:
|
||||||
|
if limit < 1 or limit > 100:
|
||||||
|
raise ValueError("limit must be between 1 and 100")
|
||||||
|
|
||||||
|
sort_key = _sort_key()
|
||||||
|
stmt = (
|
||||||
|
select(Post, Artist, Source)
|
||||||
|
.join(Source, Post.source_id == Source.id)
|
||||||
|
.join(Artist, Source.artist_id == Artist.id)
|
||||||
|
)
|
||||||
|
if artist_id is not None:
|
||||||
|
stmt = stmt.where(Source.artist_id == artist_id)
|
||||||
|
if platform is not None:
|
||||||
|
stmt = stmt.where(Source.platform == platform)
|
||||||
|
if cursor:
|
||||||
|
cur_ts, cur_id = decode_cursor(cursor)
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
sort_key < cur_ts,
|
||||||
|
and_(sort_key == cur_ts, Post.id < cur_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(sort_key.desc(), Post.id.desc()).limit(limit + 1)
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
|
||||||
|
next_cursor: str | None = None
|
||||||
|
if len(rows) > limit:
|
||||||
|
last_post, _, _ = rows[limit - 1]
|
||||||
|
last_key = last_post.post_date or last_post.downloaded_at
|
||||||
|
next_cursor = encode_cursor(last_key, last_post.id)
|
||||||
|
rows = rows[:limit]
|
||||||
|
|
||||||
|
post_ids = [p.id for p, _, _ in rows]
|
||||||
|
thumbs_map = await self._thumbnails_for(post_ids)
|
||||||
|
atts_map = await self._attachments_for(post_ids)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
self._to_dict(post, artist, source, thumbs_map, atts_map)
|
||||||
|
for post, artist, source in rows
|
||||||
|
]
|
||||||
|
return {"items": items, "next_cursor": next_cursor}
|
||||||
|
|
||||||
|
async def get_post(self, post_id: int) -> dict | None:
|
||||||
|
row = (await self.session.execute(
|
||||||
|
select(Post, Artist, Source)
|
||||||
|
.join(Source, Post.source_id == Source.id)
|
||||||
|
.join(Artist, Source.artist_id == Artist.id)
|
||||||
|
.where(Post.id == post_id)
|
||||||
|
)).one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
post, artist, source = row
|
||||||
|
thumbs_map = await self._thumbnails_for([post.id])
|
||||||
|
atts_map = await self._attachments_for([post.id])
|
||||||
|
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
|
||||||
|
item["description_full"] = html_to_plain(post.description)
|
||||||
|
return item
|
||||||
|
|
||||||
|
# --- composition helpers ---------------------------------------------
|
||||||
|
|
||||||
|
async def _thumbnails_for(self, post_ids: list[int]) -> dict[int, dict]:
|
||||||
|
"""post_id -> {"thumbs": [...up to 6], "more": int}.
|
||||||
|
|
||||||
|
Selects THUMBNAIL_LIMIT+1 images per post via window function so we
|
||||||
|
can detect overflow in a single query.
|
||||||
|
"""
|
||||||
|
if not post_ids:
|
||||||
|
return {}
|
||||||
|
# Rank images within each post and fetch only the top THUMBNAIL_LIMIT+1.
|
||||||
|
ranked = (
|
||||||
|
select(
|
||||||
|
ImageRecord.id,
|
||||||
|
ImageRecord.primary_post_id,
|
||||||
|
ImageRecord.sha256,
|
||||||
|
ImageRecord.mime,
|
||||||
|
func.row_number().over(
|
||||||
|
partition_by=ImageRecord.primary_post_id,
|
||||||
|
order_by=ImageRecord.id.asc(),
|
||||||
|
).label("rn"),
|
||||||
|
func.count(ImageRecord.id).over(
|
||||||
|
partition_by=ImageRecord.primary_post_id,
|
||||||
|
).label("total"),
|
||||||
|
)
|
||||||
|
.where(ImageRecord.primary_post_id.in_(post_ids))
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
rows = (await self.session.execute(
|
||||||
|
select(
|
||||||
|
ranked.c.id, ranked.c.primary_post_id,
|
||||||
|
ranked.c.sha256, ranked.c.mime, ranked.c.total,
|
||||||
|
).where(ranked.c.rn <= THUMBNAIL_LIMIT)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
|
||||||
|
for img_id, pid, sha, mime, total in rows:
|
||||||
|
entry = out.setdefault(pid, {"thumbs": [], "more": 0})
|
||||||
|
entry["thumbs"].append({
|
||||||
|
"image_id": img_id,
|
||||||
|
"thumbnail_url": thumbnail_url(sha, mime),
|
||||||
|
"mime": mime,
|
||||||
|
})
|
||||||
|
# `total` is constant per partition; overflow = total - THUMBNAIL_LIMIT.
|
||||||
|
entry["more"] = max(0, total - THUMBNAIL_LIMIT)
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def _attachments_for(self, post_ids: list[int]) -> dict[int, list[dict]]:
|
||||||
|
if not post_ids:
|
||||||
|
return {}
|
||||||
|
rows = (await self.session.execute(
|
||||||
|
select(PostAttachment)
|
||||||
|
.where(PostAttachment.post_id.in_(post_ids))
|
||||||
|
.order_by(PostAttachment.id.asc())
|
||||||
|
)).scalars().all()
|
||||||
|
out: dict[int, list[dict]] = {pid: [] for pid in post_ids}
|
||||||
|
for att in rows:
|
||||||
|
out.setdefault(att.post_id, []).append({
|
||||||
|
"id": att.id,
|
||||||
|
"original_filename": att.original_filename,
|
||||||
|
"ext": att.ext,
|
||||||
|
"mime": att.mime,
|
||||||
|
"size_bytes": att.size_bytes,
|
||||||
|
"download_url": f"/api/attachments/{att.id}/download",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _to_dict(
|
||||||
|
self, post: Post, artist: Artist, source: Source,
|
||||||
|
thumbs_map: dict, atts_map: dict,
|
||||||
|
) -> dict:
|
||||||
|
plain_full = html_to_plain(post.description) if post.description else None
|
||||||
|
if plain_full is None:
|
||||||
|
description_plain, truncated = None, False
|
||||||
|
else:
|
||||||
|
description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT)
|
||||||
|
thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0})
|
||||||
|
return {
|
||||||
|
"id": post.id,
|
||||||
|
"external_post_id": post.external_post_id,
|
||||||
|
"post_url": post.post_url,
|
||||||
|
"post_title": post.post_title,
|
||||||
|
"post_date": post.post_date.isoformat() if post.post_date else None,
|
||||||
|
"downloaded_at": post.downloaded_at.isoformat(),
|
||||||
|
"description_plain": description_plain,
|
||||||
|
"description_truncated": truncated,
|
||||||
|
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
||||||
|
"source": {"id": source.id, "platform": source.platform},
|
||||||
|
"thumbnails": thumbs_entry["thumbs"],
|
||||||
|
"thumbnails_more": thumbs_entry["more"],
|
||||||
|
"attachments": atts_map.get(post.id, []),
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""FC-3e: PostFeedService unit tests.
|
||||||
|
|
||||||
|
Covers cursor encode/decode, ordering (coalesce(post_date,
|
||||||
|
downloaded_at) DESC, id DESC), artist/platform filters, pagination
|
||||||
|
boundary, and the thumbnails/attachments composition.
|
||||||
|
"""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.models import (
|
||||||
|
Artist,
|
||||||
|
ImageRecord,
|
||||||
|
Post,
|
||||||
|
PostAttachment,
|
||||||
|
Source,
|
||||||
|
)
|
||||||
|
from backend.app.services.post_feed_service import (
|
||||||
|
PostFeedService,
|
||||||
|
decode_cursor,
|
||||||
|
encode_cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
# --- Cursor round-trip ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cursor_round_trip():
|
||||||
|
ts = datetime(2026, 5, 21, 12, 34, 56, tzinfo=UTC)
|
||||||
|
c = encode_cursor(ts, 42)
|
||||||
|
assert decode_cursor(c) == (ts, 42)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_cursor_rejects_garbage():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
decode_cursor("not-base64!!!")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fixture builders -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_artist(db, name: str):
|
||||||
|
a = Artist(name=name, slug=name.lower().replace(" ", "-"))
|
||||||
|
db.add(a)
|
||||||
|
await db.flush()
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_source(db, artist_id: int, platform: str, url: str):
|
||||||
|
s = Source(artist_id=artist_id, platform=platform, url=url, enabled=True)
|
||||||
|
db.add(s)
|
||||||
|
await db.flush()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_post(
|
||||||
|
db, source_id: int, *, external_id: str,
|
||||||
|
post_date=None, downloaded_at=None, title=None, description=None,
|
||||||
|
):
|
||||||
|
p = Post(
|
||||||
|
source_id=source_id, external_post_id=external_id,
|
||||||
|
post_title=title, post_date=post_date,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
if downloaded_at is not None:
|
||||||
|
p.downloaded_at = downloaded_at
|
||||||
|
db.add(p)
|
||||||
|
await db.flush()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# --- scroll() ordering and pagination ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_orders_by_coalesce_post_date_then_downloaded_at(db):
|
||||||
|
artist = await _seed_artist(db, "alice-order")
|
||||||
|
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-order")
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
# post A: post_date older than its downloaded_at; sort key = post_date
|
||||||
|
a = await _seed_post(
|
||||||
|
db, src.id, external_id="A",
|
||||||
|
post_date=now - timedelta(days=10),
|
||||||
|
downloaded_at=now,
|
||||||
|
)
|
||||||
|
# post B: post_date NULL → sort key = downloaded_at
|
||||||
|
b = await _seed_post(
|
||||||
|
db, src.id, external_id="B",
|
||||||
|
post_date=None,
|
||||||
|
downloaded_at=now - timedelta(days=5),
|
||||||
|
)
|
||||||
|
# post C: post_date newest of all
|
||||||
|
c = await _seed_post(
|
||||||
|
db, src.id, external_id="C",
|
||||||
|
post_date=now - timedelta(days=1),
|
||||||
|
downloaded_at=now - timedelta(days=20),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page = await PostFeedService(db).scroll(cursor=None, limit=10)
|
||||||
|
ids = [item["id"] for item in page["items"]]
|
||||||
|
# Expected order by coalesce(post_date, downloaded_at) DESC:
|
||||||
|
# C (1 day ago) > B (5 days ago via downloaded_at) > A (10 days ago)
|
||||||
|
assert ids == [c.id, b.id, a.id]
|
||||||
|
assert page["next_cursor"] is None # 3 items, limit 10, no more
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_pagination_across_two_pages(db):
|
||||||
|
artist = await _seed_artist(db, "alice-page")
|
||||||
|
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-page")
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
posts = []
|
||||||
|
for i in range(5):
|
||||||
|
p = await _seed_post(
|
||||||
|
db, src.id, external_id=f"P{i}",
|
||||||
|
post_date=now - timedelta(days=i),
|
||||||
|
)
|
||||||
|
posts.append(p)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page1 = await PostFeedService(db).scroll(cursor=None, limit=2)
|
||||||
|
assert len(page1["items"]) == 2
|
||||||
|
assert page1["next_cursor"] is not None
|
||||||
|
assert [it["id"] for it in page1["items"]] == [posts[0].id, posts[1].id]
|
||||||
|
|
||||||
|
page2 = await PostFeedService(db).scroll(cursor=page1["next_cursor"], limit=2)
|
||||||
|
assert [it["id"] for it in page2["items"]] == [posts[2].id, posts[3].id]
|
||||||
|
assert page2["next_cursor"] is not None
|
||||||
|
|
||||||
|
page3 = await PostFeedService(db).scroll(cursor=page2["next_cursor"], limit=2)
|
||||||
|
assert [it["id"] for it in page3["items"]] == [posts[4].id]
|
||||||
|
assert page3["next_cursor"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Filters --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_filters_by_artist(db):
|
||||||
|
alice = await _seed_artist(db, "alice-filter")
|
||||||
|
bob = await _seed_artist(db, "bob-filter")
|
||||||
|
src_a = await _seed_source(db, alice.id, "patreon", "https://p/alice-f")
|
||||||
|
src_b = await _seed_source(db, bob.id, "patreon", "https://p/bob-f")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
pa = await _seed_post(db, src_a.id, external_id="PA", post_date=now)
|
||||||
|
await _seed_post(db, src_b.id, external_id="PB", post_date=now)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page = await PostFeedService(db).scroll(
|
||||||
|
cursor=None, limit=10, artist_id=alice.id,
|
||||||
|
)
|
||||||
|
assert [it["id"] for it in page["items"]] == [pa.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_filters_by_platform(db):
|
||||||
|
artist = await _seed_artist(db, "alice-platf")
|
||||||
|
src_p = await _seed_source(db, artist.id, "patreon", "https://p/alice-pp")
|
||||||
|
src_d = await _seed_source(db, artist.id, "deviantart", "https://d/alice-dd")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
pp = await _seed_post(db, src_p.id, external_id="PP", post_date=now)
|
||||||
|
await _seed_post(db, src_d.id, external_id="PD", post_date=now)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page = await PostFeedService(db).scroll(
|
||||||
|
cursor=None, limit=10, platform="patreon",
|
||||||
|
)
|
||||||
|
assert [it["id"] for it in page["items"]] == [pp.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_combined_artist_and_platform(db):
|
||||||
|
alice = await _seed_artist(db, "alice-combo")
|
||||||
|
bob = await _seed_artist(db, "bob-combo")
|
||||||
|
src_alice_patreon = await _seed_source(db, alice.id, "patreon", "https://p/alice-c")
|
||||||
|
src_alice_da = await _seed_source(db, alice.id, "deviantart", "https://d/alice-c")
|
||||||
|
src_bob_patreon = await _seed_source(db, bob.id, "patreon", "https://p/bob-c")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
target = await _seed_post(
|
||||||
|
db, src_alice_patreon.id, external_id="TARGET", post_date=now,
|
||||||
|
)
|
||||||
|
# Distractors — wrong platform for alice, wrong artist for patreon.
|
||||||
|
await _seed_post(db, src_alice_da.id, external_id="DA", post_date=now)
|
||||||
|
await _seed_post(db, src_bob_patreon.id, external_id="BP", post_date=now)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page = await PostFeedService(db).scroll(
|
||||||
|
cursor=None, limit=10, artist_id=alice.id, platform="patreon",
|
||||||
|
)
|
||||||
|
assert [it["id"] for it in page["items"]] == [target.id]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Dict shape and content ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_item_shape_minimal(db):
|
||||||
|
artist = await _seed_artist(db, "alice-shape")
|
||||||
|
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-sh")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
await _seed_post(
|
||||||
|
db, src.id, external_id="SHAPE", post_date=now,
|
||||||
|
title="Hello", description="<p>world</p>",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page = await PostFeedService(db).scroll(cursor=None, limit=10)
|
||||||
|
item = page["items"][0]
|
||||||
|
assert set(item.keys()) >= {
|
||||||
|
"id", "external_post_id", "post_url", "post_title",
|
||||||
|
"post_date", "downloaded_at",
|
||||||
|
"description_plain", "description_truncated",
|
||||||
|
"artist", "source",
|
||||||
|
"thumbnails", "thumbnails_more", "attachments",
|
||||||
|
}
|
||||||
|
assert item["post_title"] == "Hello"
|
||||||
|
assert item["description_plain"] == "world"
|
||||||
|
assert item["description_truncated"] is False
|
||||||
|
assert item["artist"]["slug"] == "alice-shape"
|
||||||
|
assert item["source"]["platform"] == "patreon"
|
||||||
|
# List response NEVER carries the full description.
|
||||||
|
assert "description_full" not in item
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_description_truncates_long_text(db):
|
||||||
|
artist = await _seed_artist(db, "alice-long")
|
||||||
|
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-lo")
|
||||||
|
long_text = "word " * 200 # ~1000 chars
|
||||||
|
await _seed_post(
|
||||||
|
db, src.id, external_id="LONG",
|
||||||
|
post_date=datetime.now(UTC),
|
||||||
|
description=long_text,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page = await PostFeedService(db).scroll(cursor=None, limit=10)
|
||||||
|
item = page["items"][0]
|
||||||
|
assert item["description_truncated"] is True
|
||||||
|
assert len(item["description_plain"]) <= 281 # 280 + ellipsis char
|
||||||
|
|
||||||
|
|
||||||
|
# --- Thumbnails composition (primary_post_id linkage) --------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_thumbnails_show_first_six_with_overflow_count(db):
|
||||||
|
artist = await _seed_artist(db, "alice-thumbs")
|
||||||
|
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-th")
|
||||||
|
post = await _seed_post(
|
||||||
|
db, src.id, external_id="THUMB",
|
||||||
|
post_date=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
# Seed 8 ImageRecords linked to this post (unique sha + path each).
|
||||||
|
for i in range(8):
|
||||||
|
db.add(ImageRecord(
|
||||||
|
path=f"/images/thumb-{post.id}-{i}.jpg",
|
||||||
|
sha256=f"{post.id:02d}" + f"{i:062d}",
|
||||||
|
size_bytes=10, mime="image/jpeg", width=10, height=10,
|
||||||
|
origin="downloaded", primary_post_id=post.id,
|
||||||
|
artist_id=artist.id,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page = await PostFeedService(db).scroll(cursor=None, limit=10)
|
||||||
|
item = page["items"][0]
|
||||||
|
assert len(item["thumbnails"]) == 6
|
||||||
|
assert item["thumbnails_more"] == 2
|
||||||
|
for t in item["thumbnails"]:
|
||||||
|
assert "image_id" in t
|
||||||
|
assert t["thumbnail_url"].startswith("/images/thumbs/")
|
||||||
|
assert t["mime"] == "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Attachments composition ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scroll_attachments_returns_non_media_files(db):
|
||||||
|
artist = await _seed_artist(db, "alice-att")
|
||||||
|
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-at")
|
||||||
|
post = await _seed_post(
|
||||||
|
db, src.id, external_id="ATT", post_date=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(PostAttachment(
|
||||||
|
post_id=post.id, artist_id=artist.id,
|
||||||
|
sha256="a" * 64, path="/images/att/x.pdf",
|
||||||
|
original_filename="notes.pdf", ext="pdf",
|
||||||
|
mime="application/pdf", size_bytes=12345,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
page = await PostFeedService(db).scroll(cursor=None, limit=10)
|
||||||
|
item = page["items"][0]
|
||||||
|
assert len(item["attachments"]) == 1
|
||||||
|
att = item["attachments"][0]
|
||||||
|
assert att["original_filename"] == "notes.pdf"
|
||||||
|
assert att["ext"] == "pdf"
|
||||||
|
assert att["mime"] == "application/pdf"
|
||||||
|
assert att["size_bytes"] == 12345
|
||||||
|
assert att["download_url"] == f"/api/attachments/{att['id']}/download"
|
||||||
|
|
||||||
|
|
||||||
|
# --- get_post() detail ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_post_returns_full_description(db):
|
||||||
|
artist = await _seed_artist(db, "alice-detail")
|
||||||
|
src = await _seed_source(db, artist.id, "patreon", "https://p/alice-de")
|
||||||
|
long_text = "word " * 200
|
||||||
|
p = await _seed_post(
|
||||||
|
db, src.id, external_id="DETAIL",
|
||||||
|
post_date=datetime.now(UTC),
|
||||||
|
description=long_text,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
item = await PostFeedService(db).get_post(p.id)
|
||||||
|
assert item is not None
|
||||||
|
assert "description_full" in item
|
||||||
|
# Full text is HTML-stripped but NOT truncated.
|
||||||
|
assert len(item["description_full"]) > 280
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_post_unknown_returns_none(db):
|
||||||
|
item = await PostFeedService(db).get_post(99999)
|
||||||
|
assert item is None
|
||||||
Reference in New Issue
Block a user