181 lines
8.5 KiB
TypeScript
181 lines
8.5 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { buildLinkUpdate, collectionPaths, findCollection } from "../src/api.ts";
|
|
import { Client, unwrap } from "../src/client.ts";
|
|
import { configPath, normalizeUrl, resolveCredentials } from "../src/config.ts";
|
|
import { detectFormat } from "../src/commands/data.ts";
|
|
import { toUrl } from "../src/commands/links.ts";
|
|
import { treeOrder } from "../src/commands/collections.ts";
|
|
import { splitWords } from "../src/cli.ts";
|
|
import { hostname, stripAnsi, table, timeAgo, truncate } from "../src/format.ts";
|
|
import type { Collection, Link } from "../src/types.ts";
|
|
|
|
const col = (id: number, name: string, parentId: number | null = null): Collection =>
|
|
({ id, name, parentId, ownerId: 1, description: "", color: null, icon: null, iconWeight: null, isPublic: false }) as Collection;
|
|
|
|
describe("config", () => {
|
|
test("normalizeUrl", () => {
|
|
expect(normalizeUrl("links.example.com")).toBe("https://links.example.com");
|
|
expect(normalizeUrl("http://localhost:3000/")).toBe("http://localhost:3000");
|
|
expect(normalizeUrl("https://x.test/lw/api/v1")).toBe("https://x.test/lw");
|
|
expect(() => normalizeUrl("")).toThrow();
|
|
});
|
|
|
|
test("configPath honors env", () => {
|
|
expect(configPath({ LINKWARDEN_CONFIG: "/a/b.json" })).toBe("/a/b.json");
|
|
expect(configPath({ XDG_CONFIG_HOME: "/x" })).toBe("/x/linkwarden/config.json");
|
|
expect(configPath({ HOME: "/home/u" })).toBe("/home/u/.config/linkwarden/config.json");
|
|
});
|
|
|
|
test("resolveCredentials precedence", () => {
|
|
const config = { current: "work", profiles: { work: { url: "https://w.test", token: "wt" }, home: { url: "https://h.test", token: "ht" } } };
|
|
expect(resolveCredentials({ config, env: {} })).toMatchObject({ url: "https://w.test", token: "wt", profile: "work" });
|
|
expect(resolveCredentials({ config, env: { LINKWARDEN_PROFILE: "home" } }).token).toBe("ht");
|
|
expect(resolveCredentials({ config, env: { LINKWARDEN_TOKEN: "env" } }).token).toBe("env");
|
|
expect(resolveCredentials({ config, token: "flag", env: { LINKWARDEN_TOKEN: "env" } }).token).toBe("flag");
|
|
expect(() => resolveCredentials({ config: { profiles: {} }, env: {} })).toThrow(/Not logged in/);
|
|
expect(() => resolveCredentials({ config, profile: "nope", env: {} })).toThrow(/does not exist/);
|
|
});
|
|
});
|
|
|
|
describe("client", () => {
|
|
test("unwrap handles both response shapes", () => {
|
|
expect(unwrap({ response: [1] })).toEqual([1]);
|
|
expect(unwrap({ data: { a: 1 }, success: true })).toEqual({ a: 1 });
|
|
expect(unwrap("plain")).toBe("plain");
|
|
});
|
|
|
|
test("url building", () => {
|
|
const client = new Client({ url: "https://x.test/", token: "t" });
|
|
expect(client.url("/links", { a: 1, b: undefined, c: "x y" })).toBe("https://x.test/api/v1/links?a=1&c=x+y");
|
|
expect(client.url("/api/v2/dashboard")).toBe("https://x.test/api/v2/dashboard");
|
|
});
|
|
|
|
test("maps HTML error pages to a short message", async () => {
|
|
const client = new Client({ url: "https://x.test", token: "t", fetch: async () => new Response("<html>bad gateway</html>", { status: 502, statusText: "Bad Gateway" }) });
|
|
await expect(client.get("/links")).rejects.toThrow("502 Bad Gateway");
|
|
});
|
|
|
|
test("reports unreachable servers", async () => {
|
|
const client = new Client({ url: "https://x.test", fetch: async () => { throw new Error("ECONNREFUSED"); } });
|
|
await expect(client.get("/links")).rejects.toThrow(/Cannot reach https:\/\/x.test: ECONNREFUSED/);
|
|
});
|
|
|
|
test("detects non-JSON success responses", async () => {
|
|
const client = new Client({ url: "https://x.test", fetch: async () => new Response("<html></html>") });
|
|
await expect(client.get("/links")).rejects.toThrow(/not JSON/);
|
|
});
|
|
});
|
|
|
|
describe("collections", () => {
|
|
const all = [col(1, "Unorganized"), col(2, "Dev"), col(3, "Rust", 2), col(4, "Rust", 5), col(5, "Other")];
|
|
|
|
test("paths and lookup", () => {
|
|
expect(collectionPaths(all).get(3)).toBe("Dev/Rust");
|
|
expect(findCollection(all, "2")?.name).toBe("Dev");
|
|
expect(findCollection(all, "dev")?.id).toBe(2);
|
|
expect(findCollection(all, "Dev/Rust")?.id).toBe(3);
|
|
expect(findCollection(all, "missing")).toBeUndefined();
|
|
});
|
|
|
|
test("ambiguous names fail with candidates", () => {
|
|
expect(() => findCollection(all, "Rust")).toThrow(/Several collections match/);
|
|
});
|
|
|
|
test("tree order puts children after parents and survives cycles", () => {
|
|
const order = treeOrder([col(3, "Rust", 2), col(2, "Dev"), col(9, "Orphan", 77), col(10, "A", 11), col(11, "B", 10)]);
|
|
expect(order.map((o) => [o.collection.id, o.depth])).toEqual([[2, 0], [3, 1], [9, 0]]);
|
|
});
|
|
});
|
|
|
|
describe("links", () => {
|
|
const link = {
|
|
id: 5, name: "N", url: "https://a.test", description: "d", icon: null, iconWeight: null, color: null, collectionId: 1,
|
|
collection: { id: 1, name: "C", ownerId: 7 }, tags: [{ id: 1, name: "t" }], pinnedBy: [{ id: 7 }],
|
|
} as unknown as Link;
|
|
|
|
test("buildLinkUpdate keeps current values and drops pinnedBy unless pinning", () => {
|
|
const body = buildLinkUpdate(link, { name: "New" });
|
|
expect(body).toMatchObject({ id: 5, name: "New", url: "https://a.test", collection: { id: 1, ownerId: 7 }, tags: [{ id: 1, name: "t" }] });
|
|
expect("pinnedBy" in body).toBe(false);
|
|
});
|
|
|
|
test("buildLinkUpdate pin markers", () => {
|
|
expect(buildLinkUpdate(link, { pinned: true }, 7).pinnedBy).toEqual([{ id: 7 }]);
|
|
expect(buildLinkUpdate(link, { pinned: false }, 7).pinnedBy).toEqual([{}]);
|
|
});
|
|
});
|
|
|
|
describe("toUrl", () => {
|
|
test("adds a scheme to bare hosts and keeps existing ones", () => {
|
|
expect(toUrl("example.com/a?b=1")).toBe("https://example.com/a?b=1");
|
|
expect(toUrl("http://example.com")).toBe("http://example.com");
|
|
expect(toUrl("localhost:3000")).toBe("https://localhost:3000");
|
|
expect(toUrl("magnet://x")).toBe("magnet://x");
|
|
});
|
|
test("rejects words", () => {
|
|
expect(() => toUrl("notaurl")).toThrow(/not a valid URL/);
|
|
expect(() => toUrl("")).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("import detection", () => {
|
|
test("by extension and content", () => {
|
|
expect(detectFormat("a.html", "")).toBe("html");
|
|
expect(detectFormat("a.csv", "")).toBe("pocket");
|
|
expect(detectFormat("a.json", '{"collections":[]}')).toBe("linkwarden");
|
|
expect(detectFormat("a.json", '[{"savedAt":"x"}]')).toBe("omnivore");
|
|
expect(detectFormat("a.json", '[{"title":"x"}]')).toBe("wallabag");
|
|
expect(detectFormat("a.json", "nope")).toBeUndefined();
|
|
expect(detectFormat("a.txt", "")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("argument splitting", () => {
|
|
const groups = (name: string) => name === "links";
|
|
test("separates command words from flags", () => {
|
|
expect(splitWords(["links", "list", "-c", "Dev", "x"], groups)).toEqual({ words: ["links", "list"], rest: ["-c", "Dev", "x"] });
|
|
expect(splitWords(["--url", "https://x", "add", "u", "--name", "n"], groups)).toEqual({ words: ["add"], rest: ["--url", "https://x", "u", "--name", "n"] });
|
|
expect(splitWords(["--json", "links", "get", "5"], groups).words).toEqual(["links", "get"]);
|
|
});
|
|
test("keeps everything after -- as positional", () => {
|
|
expect(splitWords(["add", "--", "-weird"], groups)).toEqual({ words: ["add"], rest: ["--", "-weird"] });
|
|
});
|
|
});
|
|
|
|
describe("format helpers", () => {
|
|
test("truncate respects wide characters", () => {
|
|
expect(truncate("abcdef", 4)).toBe("abc…");
|
|
expect(truncate(" a b ", 10)).toBe("a b");
|
|
expect(truncate("日本語日本語", 5)).toBe("日本…");
|
|
});
|
|
|
|
test("table aligns columns", () => {
|
|
const out = stripAnsi(table([{ a: "x", b: "long value" }, { a: "yy", b: "z" }], [
|
|
{ header: "A", value: (r) => r.a },
|
|
{ header: "B", value: (r) => r.b },
|
|
]));
|
|
expect(out.split("\n")).toEqual(["A B", "x long value", "yy z"]);
|
|
});
|
|
|
|
test("table right-aligns columns marked as numbers", () => {
|
|
const out = stripAnsi(table([{ id: 5, n: "a" }, { id: 120, n: "bb" }], [
|
|
{ header: "ID", value: (r) => String(r.id), align: "right" },
|
|
{ header: "NAME", value: (r) => r.n },
|
|
]));
|
|
expect(out.split("\n")).toEqual([" ID NAME", " 5 a", "120 bb"]);
|
|
});
|
|
|
|
test("timeAgo", () => {
|
|
const now = Date.parse("2026-06-01T00:00:00Z");
|
|
expect(timeAgo("2026-05-31T23:59:30Z", now)).toBe("30s ago");
|
|
expect(timeAgo("2026-05-31T22:00:00Z", now)).toBe("2h ago");
|
|
expect(timeAgo("2026-05-20T00:00:00Z", now)).toBe("12d ago");
|
|
expect(timeAgo("2024-06-01T00:00:00Z", now)).toBe("2y ago");
|
|
expect(timeAgo(null)).toBe("");
|
|
});
|
|
|
|
test("hostname", () => {
|
|
expect(hostname("https://www.example.com/a")).toBe("example.com");
|
|
expect(hostname(null)).toBe("");
|
|
});
|
|
});
|