feat(posts): in-context anchored feed with bidirectional infinite scroll
Provenance "View post" deep-links to /posts?post_id=X, which now opens the feed centered on that post with infinite load in BOTH directions. Backend: PostFeedService.scroll gains a direction (older|newer); new around(post_id) returns a window of newer + the post + older with a cursor for each end. /api/posts accepts ?around= and ?direction=. + API tests. Frontend: posts store gains loadAround/loadOlder/loadNewer (older appends, newer prepends) with per-end cursors; PostsView's anchored mode scrolls to the post, observes top + bottom sentinels, and preserves scroll position on upward prepend so the page doesn't jump. Normal feed mode unchanged. Closes the remaining half of the post-navigation work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@ async def list_posts():
|
||||
artist_id_raw = args.get("artist_id")
|
||||
platform = args.get("platform") or None
|
||||
limit_raw = args.get("limit", "24")
|
||||
direction = args.get("direction", "older")
|
||||
around_raw = args.get("around")
|
||||
|
||||
try:
|
||||
limit = int(limit_raw)
|
||||
@@ -26,6 +28,16 @@ async def list_posts():
|
||||
if limit < 1 or limit > 100:
|
||||
return _bad("invalid_limit", detail="limit must be between 1 and 100")
|
||||
|
||||
if direction not in ("older", "newer"):
|
||||
return _bad("invalid_direction", detail="direction must be 'older' or 'newer'")
|
||||
|
||||
around_id = None
|
||||
if around_raw is not None:
|
||||
try:
|
||||
around_id = int(around_raw)
|
||||
except ValueError:
|
||||
return _bad("invalid_around", detail="around must be an integer post id")
|
||||
|
||||
artist_id = None
|
||||
if artist_id_raw is not None:
|
||||
try:
|
||||
@@ -40,11 +52,20 @@ async def list_posts():
|
||||
)
|
||||
|
||||
async with get_session() as session:
|
||||
try:
|
||||
page = await PostFeedService(session).scroll(
|
||||
cursor=cursor, artist_id=artist_id,
|
||||
svc = PostFeedService(session)
|
||||
if around_id is not None:
|
||||
result = await svc.around(
|
||||
post_id=around_id, artist_id=artist_id,
|
||||
platform=platform, limit=limit,
|
||||
)
|
||||
if result is None:
|
||||
return _bad("not_found", status=404, detail=f"post id={around_id}")
|
||||
return jsonify(result)
|
||||
try:
|
||||
page = await svc.scroll(
|
||||
cursor=cursor, artist_id=artist_id,
|
||||
platform=platform, limit=limit, direction=direction,
|
||||
)
|
||||
except ValueError as exc:
|
||||
# Service raises ValueError for malformed cursors only;
|
||||
# limit bounds are validated above.
|
||||
|
||||
@@ -56,9 +56,17 @@ class PostFeedService:
|
||||
artist_id: int | None = None,
|
||||
platform: str | None = None,
|
||||
limit: int = 24,
|
||||
direction: str = "older",
|
||||
) -> dict:
|
||||
"""Paginate the feed from `cursor`. direction='older' walks back in
|
||||
time (default, infinite-scroll down); direction='newer' walks forward
|
||||
(scroll up in an anchored view). Items are always returned in feed
|
||||
(descending) order; `next_cursor` points to the far edge in the
|
||||
requested direction (null when exhausted)."""
|
||||
if limit < 1 or limit > 100:
|
||||
raise ValueError("limit must be between 1 and 100")
|
||||
if direction not in ("older", "newer"):
|
||||
raise ValueError("direction must be 'older' or 'newer'")
|
||||
|
||||
sort_key = _sort_key()
|
||||
stmt = (
|
||||
@@ -72,22 +80,37 @@ class PostFeedService:
|
||||
stmt = stmt.where(Source.platform == platform)
|
||||
if cursor:
|
||||
cur_ts, cur_id = decode_cursor(cursor)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
if direction == "older":
|
||||
stmt = stmt.where(or_(
|
||||
sort_key < cur_ts,
|
||||
and_(sort_key == cur_ts, Post.id < cur_id),
|
||||
)
|
||||
)
|
||||
))
|
||||
else:
|
||||
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)
|
||||
if direction == "older":
|
||||
stmt = stmt.order_by(sort_key.desc(), Post.id.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(sort_key.asc(), Post.id.asc())
|
||||
stmt = stmt.limit(limit + 1)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
if direction == "newer":
|
||||
# Fetched ascending (closest-newer first); flip to feed order.
|
||||
rows = list(reversed(rows))
|
||||
|
||||
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]
|
||||
if has_more and rows:
|
||||
# Far edge in the travel direction: oldest row going older,
|
||||
# newest row going newer (rows is descending for display).
|
||||
edge_post = rows[-1][0] if direction == "older" else rows[0][0]
|
||||
edge_key = edge_post.post_date or edge_post.downloaded_at
|
||||
next_cursor = encode_cursor(edge_key, edge_post.id)
|
||||
|
||||
post_ids = [p.id for p, _, _ in rows]
|
||||
thumbs_map = await self._thumbnails_for(post_ids)
|
||||
@@ -99,6 +122,49 @@ class PostFeedService:
|
||||
]
|
||||
return {"items": items, "next_cursor": next_cursor}
|
||||
|
||||
async def around(
|
||||
self,
|
||||
*,
|
||||
post_id: int,
|
||||
artist_id: int | None = None,
|
||||
platform: str | None = None,
|
||||
limit: int = 12,
|
||||
) -> dict | None:
|
||||
"""A window centered on `post_id`: up to `limit` newer posts + the
|
||||
post + up to `limit` older posts, in feed (descending) order, with a
|
||||
cursor for each end. Returns None if the post doesn't exist."""
|
||||
anchor = (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 anchor is None:
|
||||
return None
|
||||
anchor_post, anchor_artist, anchor_source = anchor
|
||||
anchor_key = anchor_post.post_date or anchor_post.downloaded_at
|
||||
anchor_cursor = encode_cursor(anchor_key, anchor_post.id)
|
||||
|
||||
older = await self.scroll(
|
||||
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
|
||||
limit=limit, direction="older",
|
||||
)
|
||||
newer = await self.scroll(
|
||||
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
|
||||
limit=limit, direction="newer",
|
||||
)
|
||||
thumbs_map = await self._thumbnails_for([anchor_post.id])
|
||||
atts_map = await self._attachments_for([anchor_post.id])
|
||||
anchor_item = self._to_dict(
|
||||
anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map,
|
||||
)
|
||||
return {
|
||||
"items": newer["items"] + [anchor_item] + older["items"],
|
||||
"cursor_older": older["next_cursor"],
|
||||
"cursor_newer": newer["next_cursor"],
|
||||
"anchor_id": anchor_post.id,
|
||||
}
|
||||
|
||||
async def get_post(self, post_id: int) -> dict | None:
|
||||
row = (await self.session.execute(
|
||||
select(Post, Artist, Source)
|
||||
|
||||
@@ -12,6 +12,14 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
const done = ref(false)
|
||||
const filters = ref({ artist_id: null, platform: null })
|
||||
|
||||
// In-context (anchored) view: bidirectional cursors so the feed can load
|
||||
// newer posts on scroll-up and older posts on scroll-down around a post.
|
||||
const cursorOlder = ref(null)
|
||||
const cursorNewer = ref(null)
|
||||
const doneOlder = ref(false)
|
||||
const doneNewer = ref(false)
|
||||
const anchorId = ref(null)
|
||||
|
||||
function _qs() {
|
||||
const q = {}
|
||||
if (cursor.value) q.cursor = cursor.value
|
||||
@@ -51,8 +59,65 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
return await api.get(`/api/posts/${id}`)
|
||||
}
|
||||
|
||||
// Load a window centered on `postId`: newer posts above, the post, older
|
||||
// posts below. Sets both directional cursors for subsequent scrolling.
|
||||
async function loadAround(postId) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
anchorId.value = null
|
||||
try {
|
||||
const body = await api.get('/api/posts', { params: { around: postId } })
|
||||
items.value = body.items
|
||||
cursorOlder.value = body.cursor_older
|
||||
cursorNewer.value = body.cursor_newer
|
||||
doneOlder.value = body.cursor_older == null
|
||||
doneNewer.value = body.cursor_newer == null
|
||||
anchorId.value = body.anchor_id
|
||||
} catch (e) {
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (loading.value || doneOlder.value || cursorOlder.value == null) return
|
||||
loading.value = true
|
||||
try {
|
||||
const body = await api.get('/api/posts', {
|
||||
params: { cursor: cursorOlder.value, direction: 'older' },
|
||||
})
|
||||
items.value.push(...body.items)
|
||||
cursorOlder.value = body.next_cursor
|
||||
if (body.next_cursor == null) doneOlder.value = true
|
||||
} catch (e) {
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNewer() {
|
||||
if (loading.value || doneNewer.value || cursorNewer.value == null) return
|
||||
loading.value = true
|
||||
try {
|
||||
const body = await api.get('/api/posts', {
|
||||
params: { cursor: cursorNewer.value, direction: 'newer' },
|
||||
})
|
||||
items.value.unshift(...body.items)
|
||||
cursorNewer.value = body.next_cursor
|
||||
if (body.next_cursor == null) doneNewer.value = true
|
||||
} catch (e) {
|
||||
error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items, cursor, loading, done, error, filters,
|
||||
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
|
||||
loadInitial, loadMore, getPostFull,
|
||||
loadAround, loadOlder, loadNewer,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,39 +1,84 @@
|
||||
<template>
|
||||
<v-container class="pt-2 pb-6" max-width="900">
|
||||
<PostsFilterBar
|
||||
:artist-id="artistFilter"
|
||||
:platform="platformFilter"
|
||||
@update:filters="onFilters"
|
||||
/>
|
||||
<!-- In-context view: deep-linked to one post, with bidirectional infinite
|
||||
scroll — newer posts load above, older posts below. -->
|
||||
<template v-if="postIdFilter != null">
|
||||
<v-btn
|
||||
variant="text" size="small" prepend-icon="mdi-arrow-left"
|
||||
:to="{ name: 'posts' }" class="mb-2"
|
||||
>All posts</v-btn>
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
|
||||
<p>No posts yet. Subscribe to a source on the
|
||||
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
|
||||
tab to start capturing posts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
|
||||
|
||||
<div ref="sentinel" class="fc-posts__sentinel">
|
||||
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
|
||||
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
|
||||
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div ref="topSentinel" class="fc-posts__sentinel">
|
||||
<v-progress-circular
|
||||
v-if="store.loading && !store.doneNewer"
|
||||
indeterminate color="accent" size="20"
|
||||
/>
|
||||
<span v-else-if="store.doneNewer" class="fc-posts__end">Top of feed</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="p in store.items" :key="p.id" :id="`fc-post-${p.id}`"
|
||||
:class="['fc-posts__entry', { 'fc-posts__entry--anchor': p.id === store.anchorId }]"
|
||||
>
|
||||
<PostCard :post="p" />
|
||||
</div>
|
||||
|
||||
<div ref="bottomSentinel" class="fc-posts__sentinel">
|
||||
<v-progress-circular
|
||||
v-if="store.loading && !store.doneOlder"
|
||||
indeterminate color="accent" size="24"
|
||||
/>
|
||||
<span v-else-if="store.doneOlder" class="fc-posts__end">End of stream</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal feed -->
|
||||
<template v-else>
|
||||
<PostsFilterBar
|
||||
:artist-id="artistFilter"
|
||||
:platform="platformFilter"
|
||||
@update:filters="onFilters"
|
||||
/>
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
|
||||
<p>No posts yet. Subscribe to a source on the
|
||||
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
|
||||
tab to start capturing posts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
|
||||
|
||||
<div ref="sentinel" class="fc-posts__sentinel">
|
||||
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
|
||||
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usePostsStore } from '../stores/posts.js'
|
||||
import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
|
||||
@@ -48,7 +93,12 @@ const artistFilter = computed(() => {
|
||||
return raw == null ? null : Number(raw)
|
||||
})
|
||||
const platformFilter = computed(() => route.query.platform || null)
|
||||
const postIdFilter = computed(() => {
|
||||
const raw = route.query.post_id
|
||||
return raw == null ? null : Number(raw)
|
||||
})
|
||||
|
||||
// --- normal feed (downward infinite scroll) ---
|
||||
const sentinel = ref(null)
|
||||
let observer = null
|
||||
|
||||
@@ -58,7 +108,6 @@ async function reload() {
|
||||
platform: platformFilter.value,
|
||||
})
|
||||
}
|
||||
|
||||
function onFilters({ artist_id, platform }) {
|
||||
const q = { ...route.query }
|
||||
if (artist_id == null) delete q.artist_id
|
||||
@@ -67,22 +116,79 @@ function onFilters({ artist_id, platform }) {
|
||||
else q.platform = platform
|
||||
router.replace({ query: q })
|
||||
}
|
||||
|
||||
watch(() => [artistFilter.value, platformFilter.value], reload)
|
||||
|
||||
onMounted(async () => {
|
||||
await reload()
|
||||
function teardownFeed() {
|
||||
if (observer) { observer.disconnect(); observer = null }
|
||||
}
|
||||
function setupFeedObserver() {
|
||||
teardownFeed()
|
||||
if (!sentinel.value) return
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some(e => e.isIntersecting)) {
|
||||
store.loadMore()
|
||||
}
|
||||
if (entries.some((e) => e.isIntersecting)) store.loadMore()
|
||||
}, { rootMargin: '400px 0px' })
|
||||
if (sentinel.value) observer.observe(sentinel.value)
|
||||
observer.observe(sentinel.value)
|
||||
}
|
||||
|
||||
// --- in-context view (bidirectional, anchored on a post) ---
|
||||
const topSentinel = ref(null)
|
||||
const bottomSentinel = ref(null)
|
||||
let topObs = null
|
||||
let bottomObs = null
|
||||
|
||||
function teardownAround() {
|
||||
if (topObs) { topObs.disconnect(); topObs = null }
|
||||
if (bottomObs) { bottomObs.disconnect(); bottomObs = null }
|
||||
}
|
||||
function setupAroundObservers() {
|
||||
teardownAround()
|
||||
bottomObs = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) store.loadOlder()
|
||||
}, { rootMargin: '500px 0px' })
|
||||
if (bottomSentinel.value) bottomObs.observe(bottomSentinel.value)
|
||||
|
||||
topObs = new IntersectionObserver(async (entries) => {
|
||||
if (!entries.some((e) => e.isIntersecting)) return
|
||||
if (store.loading || store.doneNewer) return
|
||||
// Preserve the viewport position across an upward prepend so the page
|
||||
// doesn't jump when newer posts insert above the current view.
|
||||
const beforeH = document.documentElement.scrollHeight
|
||||
const beforeY = window.scrollY
|
||||
await store.loadNewer()
|
||||
await nextTick()
|
||||
const afterH = document.documentElement.scrollHeight
|
||||
window.scrollTo(0, beforeY + (afterH - beforeH))
|
||||
}, { rootMargin: '200px 0px' })
|
||||
if (topSentinel.value) topObs.observe(topSentinel.value)
|
||||
}
|
||||
async function loadAroundAndAnchor() {
|
||||
teardownFeed()
|
||||
await store.loadAround(postIdFilter.value)
|
||||
await nextTick()
|
||||
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
||||
if (el) el.scrollIntoView({ block: 'center' })
|
||||
setupAroundObservers()
|
||||
}
|
||||
|
||||
async function activate() {
|
||||
if (postIdFilter.value != null) {
|
||||
await loadAroundAndAnchor()
|
||||
} else {
|
||||
teardownAround()
|
||||
await reload()
|
||||
await nextTick()
|
||||
setupFeedObserver()
|
||||
}
|
||||
}
|
||||
|
||||
watch(postIdFilter, activate)
|
||||
watch(() => [artistFilter.value, platformFilter.value], async () => {
|
||||
if (postIdFilter.value != null) return
|
||||
await reload()
|
||||
await nextTick()
|
||||
setupFeedObserver()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) observer.disconnect()
|
||||
})
|
||||
onMounted(activate)
|
||||
onUnmounted(() => { teardownFeed(); teardownAround() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -103,4 +209,8 @@ onUnmounted(() => {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.fc-posts__entry--anchor :deep(.fc-post-card) {
|
||||
outline: 2px solid rgb(var(--v-theme-accent));
|
||||
outline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+67
-1
@@ -4,7 +4,7 @@ Validates list shape, cursor handling, filter validation, and detail
|
||||
endpoint. Service-level fixtures are exercised by test_post_feed_service
|
||||
— here we focus on the HTTP surface (validation, status codes, dict shape).
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -107,6 +107,72 @@ async def test_detail_404_for_unknown(client):
|
||||
assert body["error"] == "not_found"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def post_timeline(db):
|
||||
"""Five posts on distinct dates: posts[0] oldest … posts[4] newest."""
|
||||
artist = Artist(name="tl-api", slug="tl-api")
|
||||
db.add(artist)
|
||||
await db.flush()
|
||||
source = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://p/tl-api", enabled=True,
|
||||
)
|
||||
db.add(source)
|
||||
await db.flush()
|
||||
base = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
posts = []
|
||||
for i in range(5):
|
||||
p = Post(
|
||||
source_id=source.id, external_post_id=f"TL{i}",
|
||||
post_title=f"post {i}", post_date=base + timedelta(days=i),
|
||||
)
|
||||
db.add(p)
|
||||
posts.append(p)
|
||||
await db.commit()
|
||||
return artist, source, posts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_around_returns_window_with_anchor(client, post_timeline):
|
||||
_, _, posts = post_timeline
|
||||
anchor = posts[2]
|
||||
resp = await client.get(f"/api/posts?around={anchor.id}&limit=1")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert set(body.keys()) == {"items", "cursor_older", "cursor_newer", "anchor_id"}
|
||||
assert body["anchor_id"] == anchor.id
|
||||
# limit=1: one newer + anchor + one older, in feed (desc) order.
|
||||
assert [it["id"] for it in body["items"]] == [posts[3].id, posts[2].id, posts[1].id]
|
||||
assert body["cursor_older"] is not None # posts[0] still older
|
||||
assert body["cursor_newer"] is not None # posts[4] still newer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_around_404_for_unknown(client):
|
||||
resp = await client.get("/api/posts?around=999999")
|
||||
assert resp.status_code == 404
|
||||
assert (await resp.get_json())["error"] == "not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direction_newer_walks_forward(client, post_timeline):
|
||||
_, _, posts = post_timeline
|
||||
around = await client.get(f"/api/posts?around={posts[1].id}&limit=1")
|
||||
cursor_newer = (await around.get_json())["cursor_newer"]
|
||||
assert cursor_newer is not None
|
||||
resp = await client.get(f"/api/posts?cursor={cursor_newer}&direction=newer&limit=5")
|
||||
assert resp.status_code == 200
|
||||
# Newer than the window's newest (posts[2]) → posts[3], posts[4] in desc order.
|
||||
assert [it["id"] for it in (await resp.get_json())["items"]] == [posts[4].id, posts[3].id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_bad_direction(client):
|
||||
resp = await client.get("/api/posts?direction=sideways")
|
||||
assert resp.status_code == 400
|
||||
assert (await resp.get_json())["error"] == "invalid_direction"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_returns_uncapped_thumbnails(client, db):
|
||||
"""Feed query caps thumbnails at 6 for previews; detail endpoint
|
||||
|
||||
Reference in New Issue
Block a user