mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
364 lines
9.9 KiB
JavaScript
364 lines
9.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
enumerateProjectEvents,
|
|
buildProjectHomeFromFetcher,
|
|
buildProjectsFromFetcher,
|
|
} from "./projectEnumeration.ts";
|
|
|
|
function relayEvent(id, createdAt) {
|
|
return {
|
|
id: id.repeat(64),
|
|
kind: 30617,
|
|
pubkey: "a".repeat(64),
|
|
created_at: createdAt,
|
|
content: "",
|
|
tags: [["d", id]],
|
|
};
|
|
}
|
|
|
|
function fetcherFor(events) {
|
|
return async ({ limit, since, until }) =>
|
|
events
|
|
.filter(
|
|
(event) =>
|
|
(since === undefined || event.created_at >= since) &&
|
|
(until === undefined || event.created_at <= until),
|
|
)
|
|
.sort(
|
|
(left, right) =>
|
|
right.created_at - left.created_at || left.id.localeCompare(right.id),
|
|
)
|
|
.slice(0, limit);
|
|
}
|
|
|
|
test("buildProjectHomeFromFetcher scopes startup lookup to the active channel", async () => {
|
|
const owner = "a".repeat(64);
|
|
const channelId = "11111111-1111-4111-8111-111111111111";
|
|
const repositoryAddress = `30617:${owner}:relay`;
|
|
const projectEvent = {
|
|
id: "p".repeat(64),
|
|
kind: 30621,
|
|
pubkey: owner,
|
|
created_at: 200,
|
|
content: "",
|
|
tags: [
|
|
["d", "relay"],
|
|
["name", "Relay"],
|
|
["buzz-channel", channelId],
|
|
["a", repositoryAddress],
|
|
],
|
|
};
|
|
const repositoryEvent = {
|
|
id: "r".repeat(64),
|
|
kind: 30617,
|
|
pubkey: owner,
|
|
created_at: 100,
|
|
content: "",
|
|
tags: [
|
|
["d", "relay"],
|
|
["name", "Relay"],
|
|
["buzz-channel", channelId],
|
|
],
|
|
};
|
|
const calls = [];
|
|
const fetchExhaustively = async (kinds, extraFilter) => {
|
|
calls.push({ kinds, extraFilter });
|
|
if (kinds.includes(30621)) return [projectEvent];
|
|
if (kinds.includes(30617)) return [repositoryEvent];
|
|
return [];
|
|
};
|
|
|
|
const project = await buildProjectHomeFromFetcher(
|
|
fetchExhaustively,
|
|
channelId,
|
|
{ viewerPubkey: owner },
|
|
);
|
|
|
|
assert.equal(project?.projectChannelId, channelId);
|
|
assert.deepEqual(calls[0], {
|
|
kinds: [30621],
|
|
extraFilter: { "#buzz-channel": [channelId] },
|
|
});
|
|
assert.deepEqual(calls[1], {
|
|
kinds: [30617],
|
|
extraFilter: { "#buzz-channel": [channelId] },
|
|
});
|
|
assert.deepEqual(calls[2], {
|
|
kinds: [47001],
|
|
extraFilter: { "#a": [`30621:${owner}:relay`] },
|
|
});
|
|
assert.deepEqual(calls[3], {
|
|
kinds: [5],
|
|
extraFilter: { "#a": [`30621:${owner}:relay`, repositoryAddress] },
|
|
});
|
|
});
|
|
|
|
test("enumerateProjectEvents drains a tied boundary second before advancing", async () => {
|
|
const events = [
|
|
relayEvent("a", 1_000),
|
|
relayEvent("b", 900),
|
|
relayEvent("c", 900),
|
|
relayEvent("d", 800),
|
|
];
|
|
|
|
const result = await enumerateProjectEvents(fetcherFor(events), [30617], 3);
|
|
|
|
assert.deepEqual(
|
|
result.map((event) => event.id).sort(),
|
|
events.map((event) => event.id).sort(),
|
|
);
|
|
});
|
|
|
|
test("enumerateProjectEvents refuses to present a truncated boundary as complete", async () => {
|
|
const events = [
|
|
relayEvent("a", 1_000),
|
|
relayEvent("b", 1_000),
|
|
relayEvent("c", 1_000),
|
|
];
|
|
|
|
await assert.rejects(
|
|
enumerateProjectEvents(fetcherFor(events), [30617], 2),
|
|
/cannot exhaustively enumerate/,
|
|
);
|
|
});
|
|
|
|
// ── Tombstone fetch-failure gate ─────────────────────────────────────────────
|
|
//
|
|
// `buildProjectsFromFetcher` (and therefore `fetchProjects`) must throw rather
|
|
// than returning a project list when the kind:5 tombstone enumeration rejects.
|
|
// A silent empty-set substitution would resurrect every deleted head served by
|
|
// a history-retaining relay.
|
|
|
|
test("buildProjectsFromFetcher throws when kind-5 tombstone enumeration rejects", async () => {
|
|
const OWNER = "a".repeat(64);
|
|
const DTAG = "relay";
|
|
|
|
const projectEvent = {
|
|
id: "p".repeat(64),
|
|
kind: 30621,
|
|
pubkey: OWNER,
|
|
created_at: 200,
|
|
content: "",
|
|
tags: [["d", DTAG]],
|
|
};
|
|
const repoEvent = {
|
|
id: "r".repeat(64),
|
|
kind: 30617,
|
|
pubkey: OWNER,
|
|
created_at: 100,
|
|
content: "",
|
|
tags: [
|
|
["d", DTAG],
|
|
["name", DTAG],
|
|
],
|
|
};
|
|
|
|
// Fetcher: succeeds for project/repo kinds, rejects for kind:5 (tombstones).
|
|
const fetchExhaustively = async (kinds) => {
|
|
if (kinds.includes(5)) {
|
|
throw new Error("relay unavailable");
|
|
}
|
|
if (kinds.includes(30621)) return [projectEvent];
|
|
if (kinds.includes(30617)) return [repoEvent];
|
|
return [];
|
|
};
|
|
|
|
await assert.rejects(
|
|
buildProjectsFromFetcher(fetchExhaustively),
|
|
/Could not fetch project deletion records/,
|
|
"tombstone fetch failure must propagate as a throw, not an empty deletion set",
|
|
);
|
|
});
|
|
|
|
test("buildProjectsFromFetcher does not throw when tombstone enumeration succeeds with an empty result", async () => {
|
|
// Verify the happy path: no tombstones → returns projects without error.
|
|
const OWNER = "a".repeat(64);
|
|
|
|
const fetchExhaustively = async (kinds) => {
|
|
if (kinds.includes(5)) return []; // no deletions
|
|
if (kinds.includes(30621)) return []; // no explicit projects
|
|
if (kinds.includes(30617))
|
|
return [
|
|
{
|
|
id: "r".repeat(64),
|
|
kind: 30617,
|
|
pubkey: OWNER,
|
|
created_at: 100,
|
|
content: "",
|
|
tags: [
|
|
["d", "relay"],
|
|
["name", "relay"],
|
|
],
|
|
},
|
|
];
|
|
return [];
|
|
};
|
|
|
|
const projects = await buildProjectsFromFetcher(fetchExhaustively);
|
|
// Legacy project (unclaimed repo) should appear.
|
|
assert.ok(Array.isArray(projects), "must return a project array");
|
|
assert.equal(
|
|
projects.some((p) => p.legacy),
|
|
true,
|
|
"unclaimed repo must appear as legacy project",
|
|
);
|
|
});
|
|
|
|
// ── Tombstone scoping ────────────────────────────────────────────────────────
|
|
//
|
|
// Kind:5 is the app-wide NIP-09 deletion kind (every deleted chat message is
|
|
// one), so the tombstone fetch must be scoped server-side with `#a` filters
|
|
// on the announcement coordinates rather than crawling the relay's entire
|
|
// deletion history.
|
|
|
|
test("buildProjectsFromFetcher scopes the tombstone fetch to announcement coordinates", async () => {
|
|
const OWNER = "a".repeat(64);
|
|
|
|
const projectEvent = {
|
|
id: "p".repeat(64),
|
|
kind: 30621,
|
|
pubkey: OWNER,
|
|
created_at: 200,
|
|
content: "",
|
|
tags: [["d", "proj"]],
|
|
};
|
|
const repoEvent = {
|
|
id: "r".repeat(64),
|
|
kind: 30617,
|
|
pubkey: OWNER,
|
|
created_at: 100,
|
|
content: "",
|
|
tags: [
|
|
["d", "repo"],
|
|
["name", "repo"],
|
|
],
|
|
};
|
|
|
|
const deletionCalls = [];
|
|
const fetchExhaustively = async (kinds, extraFilter) => {
|
|
if (kinds.includes(5)) {
|
|
deletionCalls.push(extraFilter);
|
|
return [];
|
|
}
|
|
if (kinds.includes(30621)) return [projectEvent];
|
|
if (kinds.includes(30617)) return [repoEvent];
|
|
return [];
|
|
};
|
|
|
|
await buildProjectsFromFetcher(fetchExhaustively);
|
|
|
|
assert.equal(deletionCalls.length, 1, "one scoped tombstone query");
|
|
assert.deepEqual(deletionCalls[0], {
|
|
"#a": [`30621:${OWNER}:proj`, `30617:${OWNER}:repo`],
|
|
});
|
|
});
|
|
|
|
test("buildProjectsFromFetcher scopes revisions to announced Project coordinates", async () => {
|
|
const OWNER = "a".repeat(64);
|
|
const projectEvent = {
|
|
id: "p".repeat(64),
|
|
kind: 30621,
|
|
pubkey: OWNER,
|
|
created_at: 200,
|
|
content: "",
|
|
tags: [["d", "proj"]],
|
|
};
|
|
const revisionCalls = [];
|
|
const fetchExhaustively = async (kinds, extraFilter) => {
|
|
if (kinds.includes(30621)) return [projectEvent];
|
|
if (kinds.includes(47001)) {
|
|
revisionCalls.push(extraFilter);
|
|
return [];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
await buildProjectsFromFetcher(fetchExhaustively);
|
|
|
|
assert.deepEqual(revisionCalls, [{ "#a": [`30621:${OWNER}:proj`] }]);
|
|
});
|
|
|
|
test("buildProjectsFromFetcher skips the tombstone fetch when there are no announcements", async () => {
|
|
let deletionQueried = false;
|
|
const fetchExhaustively = async (kinds) => {
|
|
if (kinds.includes(5)) deletionQueried = true;
|
|
return [];
|
|
};
|
|
|
|
const projects = await buildProjectsFromFetcher(fetchExhaustively);
|
|
|
|
assert.deepEqual(projects, []);
|
|
assert.equal(
|
|
deletionQueried,
|
|
false,
|
|
"no coordinates means no tombstone query at all",
|
|
);
|
|
});
|
|
|
|
test("buildProjectsFromFetcher still suppresses deleted heads via the scoped fetch", async () => {
|
|
const OWNER = "a".repeat(64);
|
|
|
|
const repoEvent = {
|
|
id: "r".repeat(64),
|
|
kind: 30617,
|
|
pubkey: OWNER,
|
|
created_at: 100,
|
|
content: "",
|
|
tags: [
|
|
["d", "repo"],
|
|
["name", "repo"],
|
|
],
|
|
};
|
|
const deletionEvent = {
|
|
id: "5".repeat(64),
|
|
kind: 5,
|
|
pubkey: OWNER,
|
|
created_at: 150,
|
|
content: "",
|
|
tags: [["a", `30617:${OWNER}:repo`]],
|
|
};
|
|
|
|
const fetchExhaustively = async (kinds, extraFilter) => {
|
|
if (kinds.includes(5)) {
|
|
return extraFilter?.["#a"]?.includes(`30617:${OWNER}:repo`)
|
|
? [deletionEvent]
|
|
: [];
|
|
}
|
|
if (kinds.includes(30617)) return [repoEvent];
|
|
return [];
|
|
};
|
|
|
|
const projects = await buildProjectsFromFetcher(fetchExhaustively);
|
|
assert.deepEqual(projects, [], "deleted repo must not surface as a project");
|
|
});
|
|
|
|
test("enumerateProjectEvents stops paginating once its signal aborts", async () => {
|
|
// 3 full pages of 2 → without an abort the enumeration would fetch all of
|
|
// them plus boundary drains. Abort after the first page: the loop must
|
|
// throw before requesting another page.
|
|
const events = [
|
|
relayEvent("a", 1_000),
|
|
relayEvent("b", 900),
|
|
relayEvent("c", 800),
|
|
relayEvent("d", 700),
|
|
relayEvent("e", 600),
|
|
relayEvent("f", 500),
|
|
];
|
|
const controller = new AbortController();
|
|
let pageFetches = 0;
|
|
const fetchPage = async (filter) => {
|
|
pageFetches += 1;
|
|
const page = await fetcherFor(events)(filter);
|
|
controller.abort();
|
|
return page;
|
|
};
|
|
|
|
await assert.rejects(
|
|
enumerateProjectEvents(fetchPage, [30617], 2, undefined, controller.signal),
|
|
(error) => error.name === "AbortError",
|
|
);
|
|
assert.equal(pageFetches, 1);
|
|
});
|