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
2 changes: 2 additions & 0 deletions src/testing/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from "./FakeInstance";
export * from "./lemmyv1";
export * from "./pagination";
export * from "./piefed";
export * from "./search";
export * from "./seed";
export type * from "./wire";
27 changes: 27 additions & 0 deletions src/testing/lemmyv1/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,31 @@ export function createLemmyV1Builders({
};
}

function personView(subject: Wire<LemmyV1.Person>): Wire<LemmyV1.PersonView> {
return { banned: false, is_admin: false, person: subject };
}

/** `GET /api/v4/search` (search) */
function searchResponse(
over: {
comments?: Wire<LemmyV1.CommentView>[];
communities?: Wire<LemmyV1.CommunityView>[];
nextPage?: null | string;
persons?: Wire<LemmyV1.PersonView>[];
posts?: Wire<LemmyV1.PostView>[];
} = {},
): Wire<LemmyV1.SearchResponse> {
return {
comments: over.comments ?? [],
communities: over.communities ?? [],
multi_communities: [],
next_page: over.nextPage ?? null,
persons: over.persons ?? [],
posts: over.posts ?? [],
prev_page: null,
};
}

/** `GET /api/v4/person` (getPersonDetails) */
function personResponse(
subject: Wire<LemmyV1.Person>,
Expand Down Expand Up @@ -549,10 +574,12 @@ export function createLemmyV1Builders({
pagedResponse,
person,
personResponse,
personView,
post,
postResponse,
postView,
privateMessageNotification,
privateMessageView,
searchResponse,
};
}
101 changes: 91 additions & 10 deletions src/testing/lemmyv1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
OperationDef,
RecordedCall,
} from "../FakeInstance";
import { depthOf, paginateByCursor } from "../pagination";
import { searchSeed, SeedSearchType } from "../search";
import {
SeedComment,
SeedCommunity,
Expand Down Expand Up @@ -210,8 +212,11 @@ export type LemmyV1Operation = keyof typeof LEMMY_V1_OPERATIONS;
* fake.seed.loggedInAs(alex);
* ```
*
* Derived: site, post list/detail, comment list, community, person (+
* person content), account, unread counts, notifications, modlog. Use
* Derived: site, post list/detail, comment list (honoring `parent_id` and
* `max_depth`), search, community, person (+ person content), account,
* unread counts, notifications, modlog, and the vote/save/create/edit/
* delete/mark-read writes (which mutate the store). Lists paginate with
* opaque `page_cursor` strings, like the real server. Use
* `mock()` for error injection or endpoints outside this set, and
* `calls()` / `waitForCall()` to assert on outgoing requests. Wire-level
* builders stay available on `build`.
Expand Down Expand Up @@ -284,7 +289,7 @@ export class FakeLemmyV1Instance extends FakeInstance {
});
const commentView = (subject: SeedComment) =>
build.commentView({
child_count: subject.childCount,
child_count: seed.childCountOf(subject),
content: subject.content,
creator: person(subject.creator),
deleted: subject.deleted,
Expand Down Expand Up @@ -316,6 +321,23 @@ export class FakeLemmyV1Instance extends FakeInstance {
recipient_id: seed.loggedInPerson?.id ?? 0,
});

// v1 pages with opaque cursors the server round-trips
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),
});
};
const pagedFrom = <T, W>(
items: T[],
call: RecordedCall,
toWire: (item: T) => W,
) => {
const { items: page, nextPage } = pageOf(items, call);
return build.pagedResponse(page.map(toWire), nextPage ?? null);
};

const notFound = { json: { error: "not_found" }, status: 404 } as const;

this.mock("GET /api/v4/site", () => ({
Expand All @@ -325,8 +347,8 @@ export class FakeLemmyV1Instance extends FakeInstance {
}),
}));

this.mock("GET /api/v4/post/list", () => ({
json: build.pagedResponse(seed.posts.map(postView)),
this.mock("GET /api/v4/post/list", (call) => ({
json: pagedFrom(seed.posts, call, postView),
}));

this.mock("GET /api/v4/post", (call) => {
Expand All @@ -339,15 +361,31 @@ export class FakeLemmyV1Instance extends FakeInstance {
this.mock("GET /api/v4/comment/list", (call) => {
const postId = call.query.get("post_id");
const parentId = call.query.get("parent_id");
const maxDepth = call.query.get("max_depth");

let comments = postId
? seed.comments.filter((comment) => comment.post.id === Number(postId))
: seed.comments;

// parent_id = the comment's subtree (path segments include it)
if (parentId)
comments = comments.filter((comment) =>
comment.path.split(".").includes(parentId),
);
return { json: build.pagedResponse(comments.map(commentView)) };

// max_depth is relative to the requested parent, so fetching a
// subtree returns that comment plus max_depth levels beneath it
if (maxDepth) {
const parent = parentId
? seed.comments.find((comment) => comment.id === Number(parentId))
: undefined;
const baseDepth = parent ? depthOf(parent.path) : 0;
comments = comments.filter(
(comment) => depthOf(comment.path) - baseDepth <= Number(maxDepth),
);
}

return { json: pagedFrom(comments, call, commentView) };
});

this.mock("GET /api/v4/community", (call) => {
Expand Down Expand Up @@ -381,7 +419,52 @@ export class FakeLemmyV1Instance extends FakeInstance {
...commentView(comment),
})),
];
return { json: build.pagedResponse(items) };
const { items: page, nextPage } = pageOf(items, call);
return { json: build.pagedResponse(page, nextPage ?? null) };
});

this.mock("GET /api/v4/search", (call) => {
// v1 sends canonical (lowercase) search types on the wire
const results = searchSeed(seed, {
term: call.query.get("search_term") ?? undefined,
type: (call.query.get("type_") ?? undefined) as
| SeedSearchType
| undefined,
});

// One cursor across the concatenated result set, matching how the
// adapter flattens the buckets into a single canonical list
const { items, nextPage } = pageOf(
[
...results.comments.map((comment) => ["comment", comment] as const),
...results.posts.map((post) => ["post", post] as const),
...results.communities.map(
(community) => ["community", community] as const,
),
...results.people.map((person) => ["person", person] as const),
],
call,
);

return {
json: build.searchResponse({
comments: items.flatMap(([kind, item]) =>
kind === "comment" ? [commentView(item)] : [],
),
communities: items.flatMap(([kind, item]) =>
kind === "community"
? [build.communityView({ community: community(item) })]
: [],
),
nextPage,
persons: items.flatMap(([kind, item]) =>
kind === "person" ? [build.personView(person(item))] : [],
),
posts: items.flatMap(([kind, item]) =>
kind === "post" ? [postView(item)] : [],
),
}),
};
});

this.mock("GET /api/v4/modlog", () => ({
Expand Down Expand Up @@ -422,9 +505,7 @@ export class FakeLemmyV1Instance extends FakeInstance {
notifications = notifications.filter(
(notification) => !notification.read,
);
return {
json: build.pagedResponse(notifications.map(notificationView)),
};
return { json: pagedFrom(notifications, call, notificationView) };
});

// Fire-and-forget side effect of many logged-in interactions
Expand Down
83 changes: 83 additions & 0 deletions src/testing/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Pagination for derived fake responses.
*
* The two supported softwares page differently — Lemmy v1 hands out opaque
* cursor strings, PieFed uses 1-based page numbers — so the fakes derive
* pages the same way their real counterparts do, and consumers exercise
* their real pagination code paths.
*/

/**
* Note: real servers reject a non-positive or non-numeric `limit` (Lemmy
* 400s with `invalid_fetch_limit`) and a corrupt cursor. These helpers
* degrade to an empty page / offset 0 instead — never to a cursor that
* 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;
}

/**
* Depth of a comment from its materialized path (`0.24.27` → 2); top-level
* comments are depth 1.
*/
export function depthOf(path: string): number {
return path.split(".").length - 1;
}

/**
* Lemmy v1 style: opaque `page_cursor` strings the server round-trips.
*/
export function paginateByCursor<T>(
items: T[],
{ cursor, limit }: { cursor?: string; limit?: number },
): Page<T> {
const offset = cursor?.startsWith(CURSOR_PREFIX)
? Number(cursor.slice(CURSOR_PREFIX.length))
: 0;
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,
};
}

/**
* PieFed style: 1-based `page` numbers. Without a `limit` there's only one
* page, so later pages are empty (matching a server that has nothing more
* to give).
*/
export function paginateByPage<T>(
items: T[],
{ limit, page }: { limit?: number; page?: number },
): Page<T> {
const current = Math.max(1, page ?? 1);

if (limit === undefined)
return { items: current > 1 ? [] : items, nextPage: undefined };

const start = (current - 1) * limit;
const end = start + limit;
const items_ = items.slice(start, end);

return {
items: items_,
nextPage:
limit > 0 && items_.length === limit ? String(current + 1) : undefined,
};
}
20 changes: 20 additions & 0 deletions src/testing/piefed/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,25 @@ export function createPiefedBuilders({
};
}

/** `GET /api/alpha/search` (search) */
function searchResponse(
over: {
comments?: Wire<Schemas["CommentView"]>[];
communities?: Wire<Schemas["CommunityView"]>[];
posts?: Wire<Schemas["PostView"]>[];
type_?: Schemas["SearchResponse"]["type_"];
users?: Wire<Schemas["PersonView"]>[];
} = {},
): Wire<Schemas["SearchResponse"]> {
return {
comments: over.comments ?? [],
communities: over.communities ?? [],
posts: over.posts ?? [],
type_: over.type_ ?? "Posts",
users: over.users ?? [],
};
}

/** `GET /api/alpha/user` (getPersonDetails) */
function userResponse(
subject: Wire<Schemas["Person"]>,
Expand Down Expand Up @@ -435,6 +454,7 @@ export function createPiefedBuilders({
privateMessageListResponse,
privateMessageView,
repliesResponse,
searchResponse,
userResponse,
};
}
Loading