linkwarden-cli/test/mock-server.ts
KaKi87 0a484efe2f Add Linkwarden CLI
Bun-based command-line client covering the Linkwarden API: links,
collections, tags, highlights, RSS, tokens, settings, import and export.
Includes unit tests, an in-memory mock server suite, and an opt-in e2e
suite for a real server.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:54:19 +01:00

177 lines
10 KiB
TypeScript

import type { Collection, Link, Tag } from "../src/types.ts";
export interface Recorded {
method: string;
path: string;
query: Record<string, string>;
body: any;
auth: string | null;
}
/**
* A small in-memory imitation of the Linkwarden API, following the behavior of the
* route handlers in apps/web/pages/api/v1. It records requests for assertions.
*/
export function createMockServer() {
const requests: Recorded[] = [];
const me = { id: 1, username: "alice", name: "Alice", email: "alice@example.com" };
const token = "valid-token";
let nextId = 100;
const collections: Collection[] = [
{ id: 1, name: "Unorganized", description: "", color: null, icon: null, iconWeight: null, isPublic: false, ownerId: 1, parentId: null, members: [], _count: { links: 2 } },
{ id: 2, name: "Dev", description: "dev stuff", color: "#0ea5e9", icon: null, iconWeight: null, isPublic: false, ownerId: 1, parentId: null, members: [{ userId: 2, canCreate: true, canUpdate: false, canDelete: false, user: { username: "bob", name: "Bob" } }], _count: { links: 1 } },
{ id: 3, name: "Rust", description: "", color: null, icon: null, iconWeight: null, isPublic: false, ownerId: 1, parentId: 2, members: [], _count: { links: 0 } },
];
const tags: Tag[] = [
{ id: 10, name: "article", ownerId: 1, _count: { links: 2 } },
{ id: 11, name: "todo", ownerId: 1, _count: { links: 1 } },
];
const links: Link[] = [
mkLink(1, "Example", "https://example.com/", collections[0]!, [tags[0]!], [{ id: 1 }]),
mkLink(2, "Bun docs", "https://bun.sh/docs", collections[1]!, [tags[0]!, tags[1]!], []),
mkLink(3, "Other", "https://other.test/", collections[0]!, [], []),
];
function mkLink(id: number, name: string, url: string, collection: Collection, t: Tag[], pinnedBy: { id: number }[]): Link {
return {
id,
name,
url,
type: "url",
description: "",
collectionId: collection.id,
collection: { id: collection.id, name: collection.name, ownerId: collection.ownerId },
tags: t,
pinnedBy,
createdAt: "2026-01-01T00:00:00.000Z",
image: id === 2 ? "archives/2.png" : null,
pdf: id === 2 ? "archives/2.pdf" : null,
} as Link;
}
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname.replace(/^\/api\/v[12]/, "");
const query = Object.fromEntries(url.searchParams);
const isMultipart = req.headers.get("content-type")?.startsWith("multipart/");
const body: any = isMultipart ? await req.formData() : req.method === "GET" ? undefined : await req.json().catch(() => undefined);
requests.push({ method: req.method, path, query, body, auth: req.headers.get("authorization") });
if (path === "/session" && req.method === "POST") {
if (body.username === "alice" && body.password === "password123") return json({ response: { token } });
return json({ response: "Invalid credentials." }, 400);
}
if (path === "/config") return json({ response: { DISABLE_REGISTRATION: false, PAGINATION_TAKE_COUNT: 50 } });
if (path.startsWith("/public/users/")) {
const name = path.split("/").pop();
if (name === "bob") return json({ response: { id: 2, name: "Bob", username: "bob" } });
if (name === "alice") return json({ response: { id: 1, name: "Alice", username: "alice" } });
return json({ response: "User not found." }, 404);
}
if (path.startsWith("/public/")) {
if (path === "/public/collections/1") return json({ response: { ...collections[0], isPublic: true } });
if (path === "/public/collections/links") return json({ data: { links: [links[0]], nextCursor: null }, success: true });
}
if (req.headers.get("authorization") !== `Bearer ${token}`) return json({ response: "You must be logged in." }, 401);
if (path === "/users/me") return json({ response: { ...me, archiveAsPDF: true, linksRouteTo: "ORIGINAL" } });
if (path === "/users/1" && req.method === "PUT") return json({ response: me });
if (path === "/collections" && req.method === "GET") return json({ response: collections });
if (path === "/collections" && req.method === "POST") {
const c = { ...collections[0]!, id: nextId++, name: body.name, parentId: body.parentId ?? null, members: [] };
collections.push(c);
return json({ response: c });
}
const collectionMatch = path.match(/^\/collections\/(\d+)$/);
if (collectionMatch) {
const c = collections.find((x) => x.id === Number(collectionMatch[1]));
if (!c) return json({ response: "Collection is not accessible." }, 401);
if (req.method === "GET") return json({ response: c });
if (req.method === "PUT") return json({ response: { ...c, ...body } });
if (req.method === "DELETE") return json({ response: c });
}
if (path === "/search") {
let result = links.slice();
if (query.collectionId) result = result.filter((l) => l.collectionId === Number(query.collectionId));
if (query.searchQueryString) result = result.filter((l) => l.name.toLowerCase().includes(query.searchQueryString!.toLowerCase()));
// Imitates the server bug where a tag filter returns everything.
// Paginates by 2 to exercise cursors.
const start = query.cursor ? result.findIndex((l) => l.id === Number(query.cursor)) + 1 : 0;
const page = result.slice(start, start + 2);
const nextCursor = page.length === 2 && start + 2 < result.length ? page[1]!.id : null;
return json({ data: { links: page, nextCursor }, success: true, message: "Success" });
}
if (path === "/links" && req.method === "POST") {
if (body.url === "https://dup.test") return json({ response: "Link already exists" }, 409);
const collection = collections.find((c) => c.id === (body.collection?.id ?? 1))!;
const link = mkLink(nextId++, body.name || body.url || "", body.url ?? null, collection, (body.tags ?? []).map((t: any, i: number) => ({ id: 500 + i, name: t.name })), []);
link.type = body.type ?? "url";
links.push(link);
return json({ response: link });
}
if (path === "/links" && req.method === "DELETE") return json({ response: { count: body.linkIds.length } });
const linkMatch = path.match(/^\/links\/(\d+)$/);
if (linkMatch) {
const link = links.find((l) => l.id === Number(linkMatch[1]));
if (!link) return json({ response: null });
if (req.method === "GET") return json({ response: link });
if (req.method === "PUT") return json({ response: { ...link, name: body.name, tags: body.tags } });
}
if (path.match(/^\/links\/\d+\/archive$/)) return json({ response: "Link is being archived." });
if (path === "/links/archive") return json({ response: "Success." });
if (path.match(/^\/links\/\d+\/highlights$/)) return json({ response: [{ id: 7, color: "yellow", comment: null, linkId: 1, startOffset: 0, endOffset: 4, text: "text", createdAt: "2026-01-01T00:00:00Z" }] });
if (path === "/highlights" && req.method === "POST") return json({ response: { id: 8, ...body } });
if (path.match(/^\/highlights\/\d+$/)) return json({ response: {} });
const archiveMatch = path.match(/^\/archives\/(\d+)$/);
if (archiveMatch) {
if (req.method === "POST") return json({ response: {} });
return new Response(new Uint8Array([37, 80, 68, 70]), { headers: { "Content-Type": "application/pdf" } });
}
if (path === "/tags" && req.method === "GET") {
let result = tags.slice();
if (query.search) result = result.filter((t) => t.name.includes(query.search!));
return json({ data: { tags: result, nextCursor: null }, success: true });
}
if (path === "/tags" && req.method === "POST") return json({ response: body.tags.map((t: any, i: number) => ({ id: 600 + i, name: t.label })) });
if (path === "/tags" && req.method === "DELETE") return json({ response: { count: body.tagIds.length } });
if (path === "/tags/merge") return json({ response: "Success." });
const tagMatch = path.match(/^\/tags\/(\d+)$/);
if (tagMatch) {
const tag = tags.find((t) => t.id === Number(tagMatch[1]));
if (!tag) return json({ response: "Tag not found." }, 404);
if (req.method === "GET") return json({ response: tag });
if (req.method === "PUT") return json({ response: { ...tag, name: body.name } });
}
if (path === "/tokens" && req.method === "GET") return json({ response: [{ id: 1, name: "cli", userId: 1, expires: "2027-01-01T00:00:00Z", createdAt: "2026-01-01T00:00:00Z" }] });
if (path === "/tokens" && req.method === "POST") return json({ response: { secretKey: "secret-jwt", token: { id: 2, name: body.name, userId: 1, expires: "2027-01-01T00:00:00Z", createdAt: "2026-01-01T00:00:00Z" } } });
if (path.match(/^\/tokens\/\d+$/)) return json({ response: {} });
if (path === "/rss" && req.method === "GET") return json({ response: [{ id: 1, name: "Feed", url: "https://f.test/rss", collectionId: 1, collection: { name: "Unorganized" } }] });
if (path === "/rss" && req.method === "POST") return json({ response: { id: 2, collectionId: 1, ...body } });
if (path.match(/^\/rss\/\d+$/)) return json({ response: "RSS subscription deleted." });
if (path === "/migration" && req.method === "GET") return json({ collections: [], pinnedLinks: [] }); // the real route sends the backup unwrapped
if (path === "/migration" && req.method === "POST") return json({ response: "Success." });
return json({ response: "Not found." }, 404);
},
});
return {
url: `http://localhost:${server.port}`,
token,
requests,
collections,
links,
tags,
last: () => requests[requests.length - 1]!,
find: (method: string, path: string) => requests.filter((r) => r.method === method && r.path === path),
stop: () => server.stop(true),
};
}