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>
177 lines
10 KiB
TypeScript
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),
|
|
};
|
|
}
|