Add unit tests for the router, utils and caches

Covers the pure modules the rest of the app builds on, starting with the cheapest layer of the testing strategy.
This commit is contained in:
Rūdolfs Ošiņš
2026-10-01 10:20:19 +02:00
parent 6b1f0e601c
commit a99631e611
5 changed files with 562 additions and 10 deletions
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, test, vi } from "vitest";
import { cached } from "@app/lib/cached";
describe("cached", () => {
test("returns the cached result for the same key", async () => {
const f = vi.fn(async (x: number) => x * 2);
const c = cached(f, x => String(x));
expect(await c(1)).toBe(2);
expect(await c(1)).toBe(2);
expect(await c(2)).toBe(4);
expect(f).toHaveBeenCalledTimes(2);
});
test("shares a single in-flight request between concurrent callers", async () => {
let resolve: ((value: string) => void) | undefined;
const f = vi.fn(
() =>
new Promise<string>(r => {
resolve = r;
}),
);
const c = cached(f, () => "key");
const first = c();
const second = c();
resolve?.("done");
expect(await first).toBe("done");
expect(await second).toBe("done");
expect(f).toHaveBeenCalledTimes(1);
});
test("retries after a rejection", async () => {
const f = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce("ok");
const c = cached(f, () => "key");
await expect(c()).rejects.toThrow("boom");
expect(await c()).toBe("ok");
expect(f).toHaveBeenCalledTimes(2);
});
test("clear forces a new request", async () => {
const f = vi.fn(async () => "value");
const c = cached(f, () => "key");
await c();
c.clear();
await c();
expect(f).toHaveBeenCalledTimes(2);
});
test("evicts the least recently used entry", async () => {
const f = vi.fn(async (x: number) => x);
const c = cached(f, x => String(x), { max: 2 });
await c(1);
await c(2);
await c(1);
await c(3);
await c(1);
expect(f).toHaveBeenCalledTimes(3);
await c(2);
expect(f).toHaveBeenCalledTimes(4);
});
test("expires entries after the ttl", async () => {
let now = 1000;
const f = vi.fn(async () => "value");
const c = cached(f, () => "key", {
max: 1,
ttl: 1000,
ttlResolution: 0,
perf: { now: () => now },
});
await c();
now = 1500;
await c();
expect(f).toHaveBeenCalledTimes(1);
now = 2500;
await c();
expect(f).toHaveBeenCalledTimes(2);
});
});
+6 -6
View File
@@ -32,7 +32,7 @@ describe("bold", () => {
["a **|word|** here", "a |word| here"],
["a |**word**| here", "a |word| here"],
["a **||** here", "a || here"],
])("%p => %p", (input, expected) => {
])("%j => %j", (input, expected) => {
expect(format(input, "bold")).toBe(expected);
});
});
@@ -43,7 +43,7 @@ describe("italic", () => {
["a _|word|_ here", "a |word| here"],
["a |_word_| here", "a |word| here"],
["**|word|**", "**_|word|_**"],
])("%p => %p", (input, expected) => {
])("%j => %j", (input, expected) => {
expect(format(input, "italic")).toBe(expected);
});
});
@@ -56,7 +56,7 @@ describe("code", () => {
["```\n|one\ntwo|\n```", "|one\ntwo|"],
["a |one\ntwo| b", "a \n```\n|one\ntwo|\n```\n b"],
["a\n|one\ntwo|\nb", "a\n```\n|one\ntwo|\n```\nb"],
])("%p => %p", (input, expected) => {
])("%j => %j", (input, expected) => {
expect(format(input, "code")).toBe(expected);
});
});
@@ -66,7 +66,7 @@ describe("link", () => {
["see |docs| now", "see [docs](||) now"],
["see || now", "see [](||) now"],
["see |https://radicle.xyz| now", "see [||](https://radicle.xyz) now"],
])("%p => %p", (input, expected) => {
])("%j => %j", (input, expected) => {
expect(format(input, "link")).toBe(expected);
});
});
@@ -95,7 +95,7 @@ describe("pasteLinkEdit", () => {
"https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:z4D5U/issues/7bdaa5a",
"https://app.radicle.xyz/nodes/iris.radicle.xyz/rad:z4D5U/issues/7bdaa5a",
],
])("links %p", (pasted, url) => {
])("links %j", (pasted, url) => {
expect(pasteLinkEdit("docs", 0, 4, pasted)?.text).toBe(`[docs](${url})`);
});
@@ -109,7 +109,7 @@ describe("pasteLinkEdit", () => {
"just some words",
" ",
"",
])("falls through for %p", pasted => {
])("falls through for %j", pasted => {
expect(pasteLinkEdit("docs", 0, 4, pasted)).toBeUndefined();
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, test, vi } from "vitest";
import * as mutexExecutor from "@app/lib/mutexExecutor";
import { sleep } from "@app/lib/sleep";
describe("executor", () => {
test("cancels running task", async () => {
const e = mutexExecutor.create();
const first = e.run(async () => {
await sleep(10);
return "first";
});
const second = e.run(async () => {
return "second";
});
expect(await first).toBe(undefined);
expect(await second).toBe("second");
const third = e.run(async () => {
await sleep(10);
return "third";
});
const fourth = e.run(async () => {
return "fourth";
});
expect(await third).toBe(undefined);
expect(await fourth).toBe("fourth");
});
test("cancels multiple tasks", async () => {
const e = mutexExecutor.create();
const canceled1 = e.run(async () => {
await sleep(10);
return true;
});
const canceled2 = e.run(async () => {
await sleep(10);
return true;
});
const canceled3 = e.run(async () => {
await sleep(10);
return true;
});
const last = e.run(async () => {
return true;
});
expect(await canceled1).toBe(undefined);
expect(await canceled2).toBe(undefined);
expect(await canceled3).toBe(undefined);
expect(await last).toBe(true);
});
test("triggers abort signal event", async () => {
const e = mutexExecutor.create();
const abortListener = vi.fn();
void e.run(async abort => {
abort.addEventListener("abort", abortListener);
await sleep(10);
return "first";
});
expect(abortListener).not.toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/no-empty-function
void e.run(async () => {});
expect(abortListener).toHaveBeenCalled();
});
test("don’t throw error on aborted task", async () => {
const e = mutexExecutor.create();
const first = e.run(async () => {
await sleep(10);
throw new Error();
});
const second = e.run(async () => {
return "second";
});
expect(await first).toBe(undefined);
expect(await second).toBe("second");
});
});
describe("worker", () => {
test("sequential work", async () => {
const w = mutexExecutor.createWorker(async (value: number) => {
await sleep(10);
return value;
});
const outputs: number[] = [];
w.output.onValue(value => outputs.push(value));
await w.submit(1);
await w.submit(2);
await w.submit(3);
expect(outputs).toEqual([1, 2, 3]);
});
test("overlapping work cancels", async () => {
const w = mutexExecutor.createWorker(async (value: number) => {
await sleep(10);
return value;
});
const nextOutput = w.output.firstToPromise();
void w.submit(1);
void w.submit(2);
void w.submit(3);
expect(await nextOutput).toEqual(3);
});
});
+155
View File
@@ -0,0 +1,155 @@
import type { RepoRoute } from "@app/views/repo/router";
import { repoRouteToPath, repoUrlToRoute } from "@app/views/repo/router";
import { describe, expect, test } from "vitest";
const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe";
const peer = "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK";
const sha = "a".repeat(40);
function parse(path: string): RepoRoute | null {
const url = new URL(path, "http://localhost");
const segments = url.pathname.substring(1).split("/");
expect(segments.shift()).toBe("repos");
return repoUrlToRoute(segments, url.searchParams);
}
describe("repoRouteToPath and repoUrlToRoute round trip", () => {
test.each<[string, RepoRoute]>([
["/repos/RID/home", { resource: "repo.home", rid }],
["/repos/RID/home/remotes/PEER", { resource: "repo.home", rid, peer }],
[
"/repos/RID/home/feature/branch",
{ resource: "repo.home", rid, revision: "feature/branch" },
],
[
"/repos/RID/home/remotes/PEER/feature/branch",
{ resource: "repo.home", rid, peer, revision: "feature/branch" },
],
["/repos/RID/commits", { resource: "repo.commits", rid }],
[
"/repos/RID/commits/main",
{ resource: "repo.commits", rid, revision: "main" },
],
[
"/repos/RID/commits/remotes/PEER/SHA",
{ resource: "repo.commits", rid, peer, revision: sha },
],
["/repos/RID/commits/SHA", { resource: "repo.commit", rid, commit: sha }],
["/repos/RID/identity", { resource: "repo.identity", rid }],
[
"/repos/RID/issues?status=all",
{ resource: "repo.issues", rid, status: "all" },
],
[
"/repos/RID/issues?status=closed",
{ resource: "repo.issues", rid, status: "closed" },
],
[
"/repos/RID/issues/abc?status=open",
{ resource: "repo.issue", rid, issue: "abc", status: "open" },
],
[
"/repos/RID/patches",
{ resource: "repo.patches", rid, status: undefined },
],
[
"/repos/RID/patches?status=merged",
{ resource: "repo.patches", rid, status: "merged" },
],
[
"/repos/RID/patches/abc",
{
resource: "repo.patch",
rid,
patch: "abc",
status: undefined,
reviewId: undefined,
},
],
[
"/repos/RID/patches/abc?status=draft&review=def&view=changes",
{
resource: "repo.patch",
rid,
patch: "abc",
status: "draft",
reviewId: "def",
view: "changes",
},
],
])("%s", (template, route) => {
const path = template
.replace("RID", rid)
.replace("PEER", peer)
.replace("SHA", sha);
expect(repoRouteToPath(route)).toBe(path);
expect(parse(path)).toEqual(route);
});
});
describe("repoUrlToRoute", () => {
test("defaults issues listing status to all", () => {
expect(parse(`/repos/${rid}/issues`)).toEqual({
resource: "repo.issues",
rid,
status: "all",
});
});
test("falls back to all for an unknown issues listing status", () => {
expect(parse(`/repos/${rid}/issues?status=bogus`)).toEqual({
resource: "repo.issues",
rid,
status: "all",
});
});
test("defaults single issue status to all", () => {
expect(parse(`/repos/${rid}/issues/abc`)).toEqual({
resource: "repo.issue",
rid,
issue: "abc",
status: "all",
});
});
test("does not route issue creation", () => {
expect(parse(`/repos/${rid}/issues/create`)).toBeNull();
});
test("ignores an unknown patch view", () => {
expect(parse(`/repos/${rid}/patches/abc?view=bogus`)).toEqual({
resource: "repo.patch",
rid,
patch: "abc",
});
});
test("treats a sha as a revision when a peer is given", () => {
expect(parse(`/repos/${rid}/commits/remotes/${peer}/${sha}`)).toEqual({
resource: "repo.commits",
rid,
peer,
revision: sha,
});
});
test("treats a commits revision that is a sha as a single commit", () => {
const route: RepoRoute = { resource: "repo.commits", rid, revision: sha };
expect(parse(repoRouteToPath(route))).toEqual({
resource: "repo.commit",
rid,
commit: sha,
});
});
test.each([`/repos/${rid}`, `/repos/${rid}/bogus`, "/repos/"])(
"returns null for %s",
path => {
expect(parse(path)).toBeNull();
},
);
});
+192 -4
View File
@@ -1,6 +1,26 @@
import { describe, expect, test } from "vitest";
import type { Config } from "@bindings/config/Config";
import { coAuthors, safeHttpUrl } from "@app/lib/utils";
import { describe, expect, test, vi } from "vitest";
import {
coAuthors,
explorerHost,
explorerUrl,
formatRepositoryId,
formatTimestamp,
identityKey,
isCommit,
parseNodeId,
parseRepositoryId,
pluralize,
revisionPosition,
safeHttpUrl,
truncateDid,
unqualifyBranch,
} from "@app/lib/utils";
const rid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe";
const nid = "z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK";
describe("safeHttpUrl", () => {
test.each([
@@ -8,7 +28,7 @@ describe("safeHttpUrl", () => {
["https://example.com/path?a=1&b=2", "https://example.com/path?a=1&b=2"],
["HTTP://EXAMPLE.com/Path", "http://example.com/Path"],
["http://example.com/<script>", "http://example.com/%3Cscript%3E"],
])("accepts %p", (input, expected) => {
])("accepts %j", (input, expected) => {
expect(safeHttpUrl(input)).toBe(expected);
});
@@ -23,7 +43,7 @@ describe("safeHttpUrl", () => {
"relative",
"",
"not a url",
])("rejects %p", input => {
])("rejects %j", input => {
expect(safeHttpUrl(input)).toBeUndefined();
});
});
@@ -80,3 +100,171 @@ describe("coAuthors", () => {
expect(coAuthors("Subject\n\nJust a body.")).toEqual([]);
});
});
describe("parseRepositoryId", () => {
test("parses a RID with and without the rad: prefix", () => {
expect(parseRepositoryId(rid)).toEqual({
prefix: "rad:",
pubkey: "z3fpY7nttPPa6MBnAv2DccHzQJnqe",
});
expect(parseRepositoryId("z3fpY7nttPPa6MBnAv2DccHzQJnqe")).toEqual({
prefix: "rad:",
pubkey: "z3fpY7nttPPa6MBnAv2DccHzQJnqe",
});
});
test.each(["", "rad:", "rad:z3fpY7ntt", `${rid} `, "rad:x3fpY7nttPPa6MBn"])(
"rejects %j",
input => {
expect(parseRepositoryId(input)).toBeUndefined();
},
);
});
describe("formatRepositoryId", () => {
test("truncates a valid RID", () => {
expect(formatRepositoryId(rid)).toBe("rad:z3fpY7…zQJnqe");
});
test("returns an invalid RID unchanged", () => {
expect(formatRepositoryId("not-a-rid")).toBe("not-a-rid");
});
});
describe("parseNodeId", () => {
test("parses a NID with and without the did:key: prefix", () => {
expect(parseNodeId(nid)).toEqual({ prefix: "did:key:", pubkey: nid });
expect(parseNodeId(`did:key:${nid}`)).toEqual({
prefix: "did:key:",
pubkey: nid,
});
});
test("rejects a RID", () => {
expect(parseNodeId("z3fpY7nttPPa6MBnAv2DccHzQJnqe")).toBeUndefined();
});
test("rejects input that is not base58", () => {
vi.spyOn(console, "error").mockReturnValue(undefined);
expect(parseNodeId("z0OIl")).toBeUndefined();
});
});
test("truncateDid", () => {
expect(truncateDid(`did:key:${nid}`)).toBe("did:key:z6MkqG…WTvLSK");
});
test.each([
["a".repeat(40), true],
["A".repeat(40), false],
["a".repeat(39), false],
["g".repeat(40), false],
])("isCommit(%j) is %j", (input, expected) => {
expect(isCommit(input)).toBe(expected);
});
test.each([
["refs/heads/main", "main"],
["refs/heads/feature/x", "feature/x"],
["main", "main"],
["refs/tags/v1", "refs/tags/v1"],
])("unqualifyBranch(%j) is %j", (input, expected) => {
expect(unqualifyBranch(input)).toBe(expected);
});
describe("formatTimestamp", () => {
const now = Date.UTC(2026, 0, 1);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
test.each([
[0, "now"],
[59 * 1000, "now"],
[minute, "1m"],
[59 * minute, "59m"],
[hour, "1h"],
[23 * hour, "23h"],
[day, "1d"],
[30 * day, "30d"],
[31 * day, "1mo"],
[364 * day, "11mo"],
[365 * day, "1y"],
[3 * 365 * day, "3y"],
])("%j ms ago is %j", (elapsed, expected) => {
expect(formatTimestamp(now - elapsed, now)).toBe(expected);
});
});
test.each([
["issue", 0, "issues"],
["issue", 1, "issue"],
["issue", 2, "issues"],
["patch", 2, "patches"],
["box", 2, "boxes"],
["class", 2, "classes"],
])("pluralize(%j, %j) is %j", (singular, count, expected) => {
expect(pluralize(singular, count)).toBe(expected);
});
test("revisionPosition", () => {
const revisions = [{ id: "a" }, { id: "b" }, { id: "c" }];
expect(revisionPosition(revisions, "a")).toBe(1);
expect(revisionPosition(revisions, "c")).toBe(3);
expect(revisionPosition(revisions, "x")).toBeUndefined();
});
describe("identityKey", () => {
test("matches identities by email case-insensitively", () => {
expect(identityKey({ name: "Alice", email: " Alice@Example.com " })).toBe(
identityKey({ name: "A. Lidell", email: "alice@example.com" }),
);
});
test("falls back to the name when there is no email", () => {
expect(identityKey({ name: "Alice", email: "" })).toBe("name:alice");
expect(identityKey({ name: "Alice", email: "" })).not.toBe(
identityKey({ name: "Bob", email: "" }),
);
});
});
describe("explorer links", () => {
function config(overrides: Partial<Config>): Config {
return {
publicExplorer: "https://radicle.network/nodes/$host/$rid$path",
preferredSeeds: [],
...overrides,
} as Config;
}
test("uses the first preferred seed host", () => {
expect(
explorerUrl(
`${rid}/issues/abc`,
config({
preferredSeeds: [
`${nid}@seed.example.com:8776`,
`${nid}@other.example.com:8776`,
],
}),
),
).toBe(`https://radicle.network/nodes/seed.example.com/${rid}/issues/abc`);
});
test("falls back to the default seed", () => {
expect(explorerUrl(rid, config({}))).toBe(
`https://radicle.network/nodes/rosa.radicle.network/${rid}`,
);
});
test("explorerHost returns the host of the template", () => {
expect(explorerHost(config({}))).toBe("radicle.network");
});
test("explorerHost returns an unparseable template unchanged", () => {
expect(explorerHost(config({ publicExplorer: "not a url" }))).toBe(
"not a url",
);
});
});