0
2
import { afterEach, describe, expect, test, vi } from "vitest";
import { getSpaceSupport, probeSpaceSupport } from "../server/helpers/spaceSupport.ts";
const PDS = "https://pds.example.com";
/**
* The method list a pds.js deployment with PDS_ENABLE_SPACES answers with,
* trimmed to the space methods. Captured from pds.chad-53c.workers.dev.
*/
const SPACE_METHODS = [
"com.atproto.simplespace.addMember",
"com.atproto.simplespace.createSpace",
"com.atproto.simplespace.deleteSpace",
"com.atproto.simplespace.getSpace",
"com.atproto.simplespace.listMembers",
"com.atproto.simplespace.removeMember",
"com.atproto.simplespace.updateSpace",
"com.atproto.space.applyWrites",
"com.atproto.space.createRecord",
"com.atproto.space.deleteRecord",
"com.atproto.space.getBlob",
"com.atproto.space.getDelegationToken",
"com.atproto.space.getLatestCommit",
"com.atproto.space.getRecord",
"com.atproto.space.getRepo",
"com.atproto.space.getSpaceCredential",
"com.atproto.space.listRecords",
"com.atproto.space.listRepos",
"com.atproto.space.putRecord",
"com.atproto.space.registerNotify",
];
/** Stand in for a describe response listing `methods`. */
function describeResponse(methods: string[]) {
return new Response(
JSON.stringify({
roles: ["pds"],
methods: methods.map((value) => ({
$type: "community.lexicon.service.describe#nsid",
value,
})),
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
function stubFetch(response: Response | Error) {
const fn = vi.fn(async (url: string | URL | Request) => {
expect(String(url)).toBe(`${PDS}/xrpc/community.lexicon.service.describe`);
if (response instanceof Error) throw response;
return response;
});
vi.stubGlobal("fetch", fn);
return fn;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("probeSpaceSupport", () => {
test("a spaces-enabled PDS is supported", async () => {
stubFetch(describeResponse(SPACE_METHODS));
const result = await probeSpaceSupport(PDS);
expect(result.supported).toBe(true);
expect(result.missing).toEqual([]);
expect(result.pds).toBe(PDS);
});
test("accepts the reference alpha's names, which have no simplespace", async () => {
const alpha = SPACE_METHODS.filter((m) => !m.startsWith("com.atproto.simplespace.")).concat(
"com.atproto.space.createSpace",
"com.atproto.space.getSpace",
"com.atproto.memberspace.putMember",
"com.atproto.memberspace.listMembers",
);
stubFetch(describeResponse(alpha));
expect(await probeSpaceSupport(PDS)).toMatchObject({ supported: true, missing: [] });
});
test("accepts either spelling of getSpace", async () => {
// The proposal renamed things after implementations shipped, so a server
// may answer to com.atproto.space.getSpace instead.
const renamed = SPACE_METHODS.filter((m) => m !== "com.atproto.simplespace.getSpace").concat(
"com.atproto.space.getSpace",
);
stubFetch(describeResponse(renamed));
expect((await probeSpaceSupport(PDS)).supported).toBe(true);
});
test("names the methods a partial implementation is missing", async () => {
stubFetch(
describeResponse(
SPACE_METHODS.filter(
(m) => m !== "com.atproto.space.getBlob" && m !== "com.atproto.space.getSpaceCredential",
),
),
);
const result = await probeSpaceSupport(PDS);
expect(result.supported).toBe(false);
expect(result.missing).toEqual([
"com.atproto.space.getSpaceCredential",
"com.atproto.space.getBlob",
]);
});
test("a PDS with no space methods is unsupported", async () => {
stubFetch(describeResponse(["com.atproto.repo.createRecord", "com.atproto.sync.getBlob"]));
const result = await probeSpaceSupport(PDS);
expect(result.supported).toBe(false);
expect(result.missing).toHaveLength(10);
});
test("no describe endpoint reads as unsupported, not as an error", async () => {
// What every PDS that predates the proposal does, bsky.social included.
stubFetch(new Response("not found", { status: 404 }));
const result = await probeSpaceSupport(PDS);
expect(result.supported).toBe(false);
expect(result.missing).toContain("com.atproto.simplespace.createSpace");
});
test("an unreachable PDS reads as unsupported", async () => {
stubFetch(new TypeError("fetch failed"));
await expect(probeSpaceSupport(PDS)).resolves.toMatchObject({ supported: false });
});
test("a describe answer that isn't one reads as unsupported", async () => {
stubFetch(new Response("<html>hello</html>", { status: 200 }));
await expect(probeSpaceSupport(PDS)).resolves.toMatchObject({ supported: false });
});
});
/** The one row of _space_support this helper reads and writes, in memory. */
function fakeDb(row?: { supported: number; missing: string; checked_at: string }) {
let stored = row;
return {
query: async () => (stored ? [{ ...stored }] : []),
run: async (_sql: string, params?: unknown[]) => {
const [, supported, missing, checkedAt] = params as [string, number, string, string];
stored = { supported, missing, checked_at: checkedAt };
},
get stored() {
return stored;
},
};
}
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
const YES = { supported: 1, missing: "[]", checked_at: ago(0) };
const NO = { supported: 0, missing: '["com.atproto.simplespace.createSpace"]', checked_at: ago(0) };
describe("getSpaceSupport caching", () => {
test("a fresh answer is served from cache without probing", async () => {
const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
const db = fakeDb({ ...YES, checked_at: ago(60 * 1000) });
await expect(getSpaceSupport(db, PDS)).resolves.toMatchObject({ supported: true });
expect(fetchFn).not.toHaveBeenCalled();
});
test("force probes again even when the cached answer is fresh", async () => {
const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
const db = fakeDb({ ...NO, checked_at: ago(1000) });
// The recourse for a viewer whose server has just gained spaces: the thing
// that would change the answer has already happened, and without this they
// wait out a cache for it.
await expect(getSpaceSupport(db, PDS, { force: true })).resolves.toMatchObject({
supported: true,
});
expect(fetchFn).toHaveBeenCalledOnce();
});
test("a no is re-probed long before a yes would be", async () => {
// Ten minutes: past the window a no is held for, nowhere near a yes's.
const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
const db = fakeDb({ ...NO, checked_at: ago(10 * 60 * 1000) });
await expect(getSpaceSupport(db, PDS)).resolves.toMatchObject({ supported: true });
expect(fetchFn).toHaveBeenCalledOnce();
// And the fresh yes is what gets written back.
expect(db.stored?.supported).toBe(1);
});
test("a yes that old is still trusted", async () => {
const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
const db = fakeDb({ ...YES, checked_at: ago(10 * 60 * 1000) });
await expect(getSpaceSupport(db, PDS)).resolves.toMatchObject({ supported: true });
expect(fetchFn).not.toHaveBeenCalled();
});
test("a probe that could not reach the server does not outlive the minute it failed in", async () => {
// The case that stranded a real account: a restart mid-probe, cached as
// though the server had answered and said no.
const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
const db = fakeDb({ supported: 0, missing: "[]", checked_at: ago(6 * 60 * 1000) });
await expect(getSpaceSupport(db, PDS)).resolves.toMatchObject({ supported: true });
expect(fetchFn).toHaveBeenCalledOnce();
});
});