Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/testing/lemmyv1/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -147,6 +148,7 @@ export function createLemmyV1Builders({
id: number;
myVote?: -1 | 0 | 1;
name: string;
read?: boolean;
saved?: boolean;
score?: number;
url?: string;
Expand All @@ -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: [],
};
}
Expand Down
27 changes: 19 additions & 8 deletions src/testing/lemmyv1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -322,12 +323,17 @@ export class FakeLemmyV1Instance extends FakeInstance {
});

// v1 pages with opaque cursors the server round-trips
const cursors = new CursorTokens();
const pageOf = <T>(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 = <T, W>(
items: T[],
Expand Down Expand Up @@ -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
Expand Down
55 changes: 38 additions & 17 deletions src/testing/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
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<string, number>();

/** 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.
Expand All @@ -37,24 +63,19 @@ export function depthOf(path: string): number {
export function paginateByCursor<T>(
items: T[],
{ cursor, limit }: { cursor?: string; limit?: number },
tokens: CursorTokens,
): Page<T> {
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 };
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/testing/piefed/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export function createPiefedBuilders({
deleted?: boolean;
id: number;
myVote?: -1 | 0 | 1;
read?: boolean;
saved?: boolean;
score?: number;
title: string;
Expand All @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/testing/piefed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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_");
Expand Down
4 changes: 4 additions & 0 deletions src/testing/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -219,6 +221,7 @@ export class SeedStore {
id?: number;
myVote?: -1 | 0 | 1;
name: string;
read?: boolean;
saved?: boolean;
score?: number;
url?: string;
Expand All @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions test/testing-seed-matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand Down