From aa18da8484209d5b47d2724d67445e49170d4595 Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Sat, 25 Jul 2026 14:08:36 -0500 Subject: [PATCH] feat: opaque v1 cursors, piefed route parity, seeded read state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of a consumer's suite proved a hole: the v1 fake's cursor encoded its own offset, so an app that ignored the server's cursor and computed one still paged correctly and its tests passed. Cursors are now opaque tokens resolved through a per-instance map — echoing what the server sent is the only way to advance (counter-based, so runs stay reproducible). PieFed keeps page numbers; those genuinely are its API. PieFed parity: derived community/list, federated_instances, and post/mark_as_read (accepting either post_id or post_ids, as the real API does). The last one 404'd, which made a shared mark-read spec assert a request the fake was rejecting. Posts carry read state, mutated by mark-as-read on both providers, so that spec can assert the effect rather than just the request. --- src/testing/lemmyv1/builders.ts | 14 +++++--- src/testing/lemmyv1/index.ts | 27 +++++++++++----- src/testing/pagination.ts | 55 ++++++++++++++++++++++---------- src/testing/piefed/builders.ts | 3 +- src/testing/piefed/index.ts | 34 ++++++++++++++++++++ src/testing/seed.ts | 4 +++ test/testing-seed-matrix.test.ts | 38 ++++++++++++++++++++++ 7 files changed, 145 insertions(+), 30 deletions(-) diff --git a/src/testing/lemmyv1/builders.ts b/src/testing/lemmyv1/builders.ts index 3470288..207e6e3 100644 --- a/src/testing/lemmyv1/builders.ts +++ b/src/testing/lemmyv1/builders.ts @@ -92,10 +92,11 @@ export function createLemmyV1Builders({ return { downvotes, score: upvotes - downvotes, upvotes }; } - // v1 conveys the logged-in user's vote/save via *_actions; absent (`{}`) - // when there's no interaction. - function actions(myVote: -1 | 0 | 1, saved: boolean) { + // v1 conveys the logged-in user's vote/save/read via *_actions; absent + // (`{}`) when there's no interaction. + function actions(myVote: -1 | 0 | 1, saved: boolean, read = false) { return { + read_at: read ? now : undefined, saved_at: saved ? now : undefined, vote_is_upvote: myVote === 0 ? undefined : myVote === 1, voted_at: myVote === 0 ? undefined : now, @@ -147,6 +148,7 @@ export function createLemmyV1Builders({ id: number; myVote?: -1 | 0 | 1; name: string; + read?: boolean; saved?: boolean; score?: number; url?: string; @@ -162,7 +164,11 @@ export function createLemmyV1Builders({ creator_is_admin: false, creator_is_moderator: false, post: post({ ...over, community: resolvedCommunity }), - post_actions: actions(over.myVote ?? 0, over.saved ?? false), + post_actions: actions( + over.myVote ?? 0, + over.saved ?? false, + over.read ?? false, + ), tags: [], }; } diff --git a/src/testing/lemmyv1/index.ts b/src/testing/lemmyv1/index.ts index 9d72c7b..581e9a8 100644 --- a/src/testing/lemmyv1/index.ts +++ b/src/testing/lemmyv1/index.ts @@ -6,7 +6,7 @@ import { OperationDef, RecordedCall, } from "../FakeInstance"; -import { depthOf, paginateByCursor } from "../pagination"; +import { CursorTokens, depthOf, paginateByCursor } from "../pagination"; import { searchSeed, SeedSearchType } from "../search"; import { SeedComment, @@ -283,6 +283,7 @@ export class FakeLemmyV1Instance extends FakeInstance { id: subject.id, myVote: subject.myVote, name: subject.name, + read: subject.read, saved: subject.saved, score: subject.score, url: subject.url, @@ -322,12 +323,17 @@ export class FakeLemmyV1Instance extends FakeInstance { }); // v1 pages with opaque cursors the server round-trips + const cursors = new CursorTokens(); const pageOf = (items: T[], call: RecordedCall) => { const limit = call.query.get("limit"); - return paginateByCursor(items, { - cursor: call.query.get("page_cursor") ?? undefined, - limit: limit === null ? undefined : Number(limit), - }); + return paginateByCursor( + items, + { + cursor: call.query.get("page_cursor") ?? undefined, + limit: limit === null ? undefined : Number(limit), + }, + cursors, + ); }; const pagedFrom = ( items: T[], @@ -508,9 +514,14 @@ export class FakeLemmyV1Instance extends FakeInstance { return { json: pagedFrom(notifications, call, notificationView) }; }); - // Fire-and-forget side effect of many logged-in interactions - this.mock("POST /api/v4/post/mark_as_read/many", { - json: { success: true }, + this.mock("POST /api/v4/post/mark_as_read/many", (call) => { + const { post_ids, read } = call.body as { + post_ids: number[]; + read: boolean; + }; + for (const post of seed.posts) + if (post_ids.includes(post.id)) post.read = read; + return { json: { success: true } }; }); // Vote/save writes mutate the seed store, so the returned view — and diff --git a/src/testing/pagination.ts b/src/testing/pagination.ts index 1fa38b7..1a8b1e1 100644 --- a/src/testing/pagination.ts +++ b/src/testing/pagination.ts @@ -14,15 +14,41 @@ * fails to advance, so a consumer's paging loop can't spin forever. */ -/** Prefix marks fake cursors as opaque: nothing should parse them */ -const CURSOR_PREFIX = "seed-offset:"; - export interface Page { items: T[]; /** Wire `next_page` value; absent when the last page was served */ nextPage?: string; } +/** + * Hands out cursors a client cannot derive. + * + * A cursor that encodes its own offset (`offset:40`) lets a buggy consumer + * fabricate the next one and still page correctly, so tests can't tell + * "echoes the server's cursor" from "invents one". Tokens here are opaque + * counter values resolved through this map — echoing what the server sent + * is the only way to advance. Counter, not random, so runs stay + * reproducible. + */ +export class CursorTokens { + #next = 1; + + #offsets = new Map(); + + /** Mint a cursor pointing at `offset` */ + issue(offset: number): string { + const token = `seed-cursor-${this.#next++}`; + this.#offsets.set(token, offset); + return token; + } + + /** The offset a cursor refers to; unknown cursors start from the top */ + offsetOf(cursor: string | undefined): number { + if (cursor === undefined) return 0; + return this.#offsets.get(cursor) ?? 0; + } +} + /** * Depth of a comment from its materialized path (`0.24.27` → 2); top-level * comments are depth 1. @@ -37,24 +63,19 @@ export function depthOf(path: string): number { export function paginateByCursor( items: T[], { cursor, limit }: { cursor?: string; limit?: number }, + tokens: CursorTokens, ): Page { - const offset = cursor?.startsWith(CURSOR_PREFIX) - ? Number(cursor.slice(CURSOR_PREFIX.length)) - : 0; + const offset = tokens.offsetOf(cursor); const end = limit === undefined ? items.length : offset + limit; const page = items.slice(offset, end); - return { - items: page, - // Real Lemmy hands out a cursor whenever it filled the page — so the - // last full page is followed by an empty one, and consumers that stop - // on "no cursor" are exercised properly. `limit > 0` keeps a - // degenerate limit from emitting a cursor that never advances. - nextPage: - limit !== undefined && limit > 0 && page.length === limit - ? `${CURSOR_PREFIX}${end}` - : undefined, - }; + // Real Lemmy hands out a cursor whenever it filled the page — so the last + // full page is followed by an empty one, and consumers that stop on "no + // cursor" are exercised properly. `limit > 0` keeps a degenerate limit + // from emitting a cursor that never advances. + const hasMore = limit !== undefined && limit > 0 && page.length === limit; + + return { items: page, nextPage: hasMore ? tokens.issue(end) : undefined }; } /** diff --git a/src/testing/piefed/builders.ts b/src/testing/piefed/builders.ts index 38a4620..df74092 100644 --- a/src/testing/piefed/builders.ts +++ b/src/testing/piefed/builders.ts @@ -141,6 +141,7 @@ export function createPiefedBuilders({ deleted?: boolean; id: number; myVote?: -1 | 0 | 1; + read?: boolean; saved?: boolean; score?: number; title: string; @@ -167,7 +168,7 @@ export function createPiefedBuilders({ hidden: false, my_vote: myVote, post: post({ ...over, community: resolvedCommunity }), - read: false, + read: over.read ?? false, saved: over.saved ?? false, subscribed: "NotSubscribed", unread_comments: 0, diff --git a/src/testing/piefed/index.ts b/src/testing/piefed/index.ts index 8d92d73..2e8fc08 100644 --- a/src/testing/piefed/index.ts +++ b/src/testing/piefed/index.ts @@ -326,6 +326,7 @@ export class FakePiefedInstance extends FakeInstance { deleted: subject.deleted, id: subject.id, myVote: subject.myVote, + read: subject.read, saved: subject.saved, score: subject.score, title: subject.name, @@ -557,6 +558,39 @@ export class FakePiefedInstance extends FakeInstance { }; }); + this.mock("GET /api/alpha/community/list", (call) => { + const { items, nextPage } = pageOf(seed.communities, call); + return { + json: { + communities: items.map((subject) => + build.communityView({ community: community(subject) }), + ), + next_page: nextPage ?? null, + }, + }; + }); + + // Real PieFed serves this; the lemmyv1 adapter throws UnsupportedError + // before requesting, so only the piefed fake needs it + this.mock("GET /api/alpha/federated_instances", () => ({ + json: { federated_instances: { allowed: [], blocked: [], linked: [] } }, + })); + + // Fire-and-forget on the app side, but a real server answers it — an + // unmocked 404 here made the shared mark-read spec vacuous on piefed + this.mock("POST /api/alpha/post/mark_as_read", (call) => { + // PieFed accepts either a single post_id or a post_ids array + const { post_id, post_ids, read } = call.body as { + post_id?: number; + post_ids?: number[]; + read: boolean; + }; + const ids = post_ids ?? (post_id === undefined ? [] : [post_id]); + for (const post of seed.posts) + if (ids.includes(post.id)) post.read = read; + return { json: { success: true } }; + }); + this.mock("GET /api/alpha/search", (call) => { // PieFed capitalizes search types on the wire const wireType = call.query.get("type_"); diff --git a/src/testing/seed.ts b/src/testing/seed.ts index b3f889f..461581d 100644 --- a/src/testing/seed.ts +++ b/src/testing/seed.ts @@ -70,6 +70,8 @@ export interface SeedPost { /** The logged-in user's vote (mutated by like writes) */ myVote: -1 | 0 | 1; name: string; + /** Read by the logged-in user (mutated by mark-as-read writes) */ + read: boolean; /** Saved by the logged-in user (mutated by save writes) */ saved: boolean; /** Base score at `myVote` 0; the logged-in user's vote is added on top */ @@ -219,6 +221,7 @@ export class SeedStore { id?: number; myVote?: -1 | 0 | 1; name: string; + read?: boolean; saved?: boolean; score?: number; url?: string; @@ -231,6 +234,7 @@ export class SeedStore { id: over.id ?? this.#nextId++, myVote: over.myVote ?? 0, name: over.name, + read: over.read ?? false, saved: over.saved ?? false, score: over.score ?? 1, url: over.url, diff --git a/test/testing-seed-matrix.test.ts b/test/testing-seed-matrix.test.ts index eeedf90..2180591 100644 --- a/test/testing-seed-matrix.test.ts +++ b/test/testing-seed-matrix.test.ts @@ -271,6 +271,17 @@ describe.each([ expect(third.next_page).toBeUndefined(); }); + it("marks posts read, and later reads reflect it", async () => { + const { client, fake, post } = setup(); + fake.seed.loggedInAs(fake.seed.person({ name: "me" })); + + expect((await client.getPost({ id: post.id })).post_view.read).toBe(false); + + await client.markPostAsRead({ post_ids: [post.id], read: true }); + + expect((await client.getPost({ id: post.id })).post_view.read).toBe(true); + }); + it("serves a trailing empty page when the last page was full", async () => { const { client, fake } = setup(); @@ -509,6 +520,33 @@ describe.each([ }); }); +describe("lemmyv1 cursors", () => { + it("hands out cursors a consumer cannot derive", async () => { + // Real Lemmy cursors are opaque tokens. If the fake's encoded its own + // offset, a consumer that ignored the server's cursor and computed one + // would still page correctly — and its tests would pass. (PieFed is + // exempt: page numbers genuinely are its API.) + const fake = new FakeLemmyV1Instance(); + const alex = fake.seed.person({ name: "alex" }); + for (const index of [1, 2, 3, 4]) + fake.seed.post({ creator: alex, id: index, name: `Post ${index}` }); + + const client = new ThreadiverseClient(fake.origin, fake.clientOptions()); + + const first = await client.getPosts({ limit: 2 }); + expect(String(first.next_page)).not.toContain("2"); + + const second = await client.getPosts({ + limit: 2, + page_cursor: first.next_page, + }); + expect(second.data.map((view) => view.post.name)).toEqual([ + "Post 3", + "Post 4", + ]); + }); +}); + describe("seeded notifications (lemmyv1)", () => { it("derives inbox endpoints from seeded notifications", async () => { const fake = new FakeLemmyV1Instance();