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();