Files
bitvid/tests/blossom-service.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 e81e65ff40 feat(blossom): Phase 2 management — blob delete-on-orphan + kind-10063 server list (#30)
- deleteByUrl maps a Blossom URL back to its content-addressed blob (blobSha256FromUrl) and deletes it via BUD-12, defaulting to the URL's own origin. Wired into the edit-modal orphan cleanup so a superseded Blossom video/thumbnail is reclaimed (best-effort, never blocks the edit).

- BUD-03 kind-10063 server-list discovery: read (serversFromServerListEvent) + build (buildBlossomServerListEvent) + an extracted, testable import/publish helper (blossomServerList.js), plus 'Import from my server list' / 'Publish my server list' controls in the Storage pane's Blossom section.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 10:32:22 -04:00

496 lines
19 KiB
JavaScript

// blossomService tests (Node-safe — no WebTorrent). Covers the availability gate,
// server-list resolution, input guards, and the uploadVideo orchestration (blob +
// injected torrent → magnet + storagePointer). The vendored SDK is mocked via
// loadSdk; real-server round-trips are exercised in-browser. See docs/blossom-plan.md.
import test from "node:test";
import assert from "node:assert/strict";
import blossomService, {
BlossomService,
BLOSSOM_PROVIDER,
isBlossomProvider,
resolveBlossomServers,
blobSha256FromUrl,
serversFromServerListEvent,
} from "../js/services/blossomService.js";
import { FEATURE_BLOSSOM_STORAGE } from "../js/constants.js";
const SHA = "a".repeat(64);
const signer = async (draft) => ({ ...draft, id: "id", sig: "sig", pubkey: "pk" });
// A BlossomService whose SDK is mocked: uploadBlob uploads directly to each
// server (BUD-02 /upload) and returns a descriptor, echoing the blob so we can
// assert which servers were hit. `failServers` simulates per-server failures.
function mockService({ failServers = [] } = {}) {
const uploads = [];
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async (s, sha256, opts) => ({ kind: 24242, sha256, opts }),
uploadBlob: async (server, file, opts) => {
// Give each blob a distinct sha256 so video vs .torrent URLs differ.
const sha = file?.name?.includes(".torrent") ? "b".repeat(64) : SHA;
if (typeof opts?.onAuth === "function") {
await opts.onAuth(server, sha, "upload", file);
}
uploads.push({ server, file });
if (failServers.includes(server)) {
throw new Error("CORS blocked");
}
return { url: `${server}/${sha}`, sha256: sha, size: 1, type: "x" };
},
});
return { svc, uploads };
}
test("isBlossomProvider / BLOSSOM_PROVIDER only match 'blossom'", () => {
assert.equal(BLOSSOM_PROVIDER, "blossom");
assert.equal(isBlossomProvider("blossom"), true);
assert.equal(isBlossomProvider("cloudflare_r2"), false);
});
test("isAvailable reflects the FEATURE_BLOSSOM_STORAGE flag", () => {
assert.equal(blossomService.isAvailable(), FEATURE_BLOSSOM_STORAGE === true);
});
test("resolveBlossomServers de-dupes, trims, and reads meta.servers", () => {
assert.deepEqual(
resolveBlossomServers({ servers: [" https://a ", "https://a", "https://b", "", " "] }),
["https://a", "https://b"],
);
assert.deepEqual(resolveBlossomServers({ meta: { servers: ["https://c"] } }), ["https://c"]);
assert.deepEqual(resolveBlossomServers({}), []);
});
test("uploadFile guards: file, servers, signer required", async () => {
const { svc } = mockService();
await assert.rejects(() => svc.uploadFile({ servers: ["https://s"], signer }), /requires a file/);
await assert.rejects(
() => svc.uploadFile({ file: new Blob(["x"]), servers: [], signer }),
/at least one server/,
);
await assert.rejects(
() => svc.uploadFile({ file: new Blob(["x"]), servers: ["https://s"], signer: null }),
/requires a signer/,
);
});
test("uploadFile uploads directly to every server and returns the primary url", async () => {
const { svc, uploads } = mockService();
const out = await svc.uploadFile({
file: new File([new Uint8Array([1])], "v.mp4"),
servers: ["https://a", "https://b"],
signer,
});
assert.equal(out.url, `https://a/${SHA}`);
assert.equal(out.key, SHA);
assert.deepEqual(
uploads.map((u) => u.server).sort(),
["https://a", "https://b"],
"uploaded to both servers directly (no /mirror)",
);
});
test("uploadFile survives partial failure — one server down, another succeeds", async () => {
const { svc } = mockService({ failServers: ["https://a"] });
const out = await svc.uploadFile({
file: new File([new Uint8Array([1])], "v.mp4"),
servers: ["https://a", "https://b"],
signer,
});
assert.equal(out.url, `https://b/${SHA}`, "falls back to the working server");
});
test("uploadFile throws a descriptive error when ALL servers fail", async () => {
const { svc } = mockService({ failServers: ["https://a", "https://b"] });
await assert.rejects(
() =>
svc.uploadFile({
file: new File([new Uint8Array([1])], "v.mp4"),
servers: ["https://a", "https://b"],
signer,
}),
/failed on all server\(s\).*CORS blocked/s,
);
});
test("uploadFile declares a Content-Type when the File has none (avoids 415)", async () => {
// Browsers leave File.type empty for containers like .m4v; the SDK then PUTs a
// typeless body and strict servers answer 415. uploadFile must re-type the blob.
const { svc, uploads } = mockService();
const typeless = new File([new Uint8Array([1])], "clip.m4v");
assert.equal(typeless.type, "", "precondition: the File has no MIME type");
await svc.uploadFile({ file: typeless, servers: ["https://a"], signer });
assert.equal(
uploads[0].file.type,
"video/mp4",
"the uploaded blob was re-typed from the .m4v extension",
);
});
test("uploadFile overrides a generic application/octet-stream type from the extension", async () => {
// Files produced by other apps or fetched via WebTorrent often carry a generic
// "application/octet-stream" type; strict servers reject that with 415/400 even
// though they accept the real type. A recognized extension must win.
const { svc, uploads } = mockService();
const generic = new File([new Uint8Array([1])], "Fire Pit Render.mp4", {
type: "application/octet-stream",
});
await svc.uploadFile({ file: generic, servers: ["https://a"], signer });
assert.equal(
uploads[0].file.type,
"video/mp4",
"generic octet-stream is replaced by video/mp4 from the .mp4 extension",
);
});
test("uploadFile keeps a specific, correct browser type untouched", async () => {
const { svc, uploads } = mockService();
const good = new File([new Uint8Array([1])], "clip.mkv", { type: "video/webm" });
await svc.uploadFile({ file: good, servers: ["https://a"], signer });
// A specific (non-generic) browser type is trusted even if it differs from the
// extension's inferred type — we don't second-guess a real declared type.
assert.equal(uploads[0].file.type, "video/webm");
});
test("uploadFile surfaces the server HTTP status in the all-fail error", async () => {
// Simulate a Blossom server rejecting the media type (415). The SDK's HTTPError
// exposes `.status`; the thrown message must include it, not just a vague reason.
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async () => {
const err = new Error("Something went wrong");
err.status = 415;
throw err;
},
});
await assert.rejects(
() =>
svc.uploadFile({
file: new File([new Uint8Array([1])], "v.mp4"),
servers: ["https://a"],
signer,
}),
/HTTP 415: Something went wrong/,
);
});
test("uploadVideo without a torrent returns a blossom storagePointer and no magnet", async () => {
const { svc } = mockService();
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({ hasValidInfoHash: false, torrentFile: null }),
});
assert.equal(result.url, `https://a/${SHA}`);
assert.equal(result.key, SHA);
assert.match(result.storagePointer, /blossom/);
assert.equal(result.magnet, "");
assert.equal(result.hasValidInfoHash, false);
});
test("uploadVideo with a torrent uploads the .torrent as a blob and builds a magnet (ws + xs)", async () => {
const { svc, uploads } = mockService();
const infoHash = "0".repeat(40);
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async ({ videoPublicUrl }) => {
assert.equal(videoPublicUrl, `https://a/${SHA}`, "ws is the blossom video url");
return {
hasValidInfoHash: true,
infoHash,
torrentFile: new File([new Uint8Array([9])], "clip.torrent"),
};
},
});
assert.equal(uploads.length, 2, "video blob + .torrent blob");
assert.equal(result.hasValidInfoHash, true);
assert.equal(result.infoHash, infoHash);
assert.match(result.magnet, new RegExp(`btih:${infoHash}`));
assert.match(result.magnet, /ws=https%3A%2F%2Fa%2F/, "ws = blossom video url");
assert.match(result.magnet, /xs=https%3A%2F%2Fa%2F/, "xs = blossom .torrent url");
assert.equal(result.torrentUrl, `https://a/${"b".repeat(64)}`);
});
test("uploadVideo publishes URL-only when the .torrent sidecar is rejected (415)", async () => {
// Spec correction: a webseed-only magnet (ws= but no xs=) can't bootstrap
// WebTorrent — there's no metadata source — so shipping one produces a dead
// share link. When a Blossom server rejects the .torrent (415), uploadVideo must
// succeed with the video URL but publish NO magnet (URL-only), while retaining
// the generated .torrent locally for other storage targets.
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed, unsupported");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const infoHash = "0".repeat(40);
const torrentFile = new File([new Uint8Array([9])], "clip.torrent");
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({ hasValidInfoHash: true, infoHash, torrentFile }),
});
assert.equal(result.url, `https://a/${SHA}`, "video url is returned");
assert.equal(result.magnet, "", "no magnet is published when the .torrent isn't hosted");
assert.equal(result.torrentUrl, "", "no hosted .torrent url after rejection");
assert.equal(result.hasValidInfoHash, false, "no usable magnet is advertised");
assert.equal(result.torrentFile, torrentFile, "the generated .torrent is retained locally");
});
test("uploadVideo Tier 2: publishes a companion metadata event → ws=-only magnet", async () => {
// .torrent rejected by the server (415), but a publisher is wired (flag on at the
// mediaUploader seam): bitvid publishes the piece-map companion event and ships a
// ws=-only magnet it can bootstrap from Nostr. No xs= (nothing hosted).
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const infoHash = "0".repeat(40);
const published = [];
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1, 2])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({
hasValidInfoHash: true,
infoHash,
torrentFile: new File([new Uint8Array([9, 9, 9])], "clip.torrent"),
}),
publishTorrentMetadata: async (args) => published.push(args),
});
assert.equal(published.length, 1, "the companion was published exactly once");
assert.equal(published[0].infoHash, infoHash);
assert.equal(
Buffer.from(published[0].torrentBase64, "base64").length,
3,
"the exact .torrent bytes were base64-encoded",
);
assert.equal(result.hasValidInfoHash, true);
assert.match(result.magnet, new RegExp(`btih:${infoHash}`));
assert.match(result.magnet, /ws=https%3A%2F%2Fa%2F/, "webseed points at the video");
assert.ok(!/xs=/.test(result.magnet), "no xs= — the .torrent isn't hosted");
});
test("uploadVideo Tier 2: over-cap piece-map falls back to URL-only", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const published = [];
// A .torrent whose base64 exceeds the 64 KiB cap.
const big = new File([new Uint8Array(60 * 1024)], "big.torrent");
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({
hasValidInfoHash: true,
infoHash: "0".repeat(40),
torrentFile: big,
}),
publishTorrentMetadata: async (args) => published.push(args),
});
assert.equal(published.length, 0, "over-cap ⇒ nothing published");
assert.equal(result.magnet, "", "URL-only");
assert.equal(result.hasValidInfoHash, false);
});
test("uploadVideo Tier 2: a failed companion publish falls back to URL-only", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createUploadAuth: async () => ({ kind: 24242 }),
uploadBlob: async (server, file) => {
if (file?.name?.includes(".torrent")) {
const err = new Error("File type not allowed");
err.status = 415;
throw err;
}
return { url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" };
},
});
const result = await svc.uploadVideo({
file: new File([new Uint8Array([1])], "clip.mp4"),
servers: ["https://a"],
signer,
generateTorrent: async () => ({
hasValidInfoHash: true,
infoHash: "0".repeat(40),
torrentFile: new File([new Uint8Array([9])], "clip.torrent"),
}),
publishTorrentMetadata: async () => {
throw new Error("relay unreachable");
},
});
assert.equal(result.magnet, "", "publish failed ⇒ URL-only, video still succeeds");
assert.equal(result.hasValidInfoHash, false);
assert.equal(result.url, `https://a/${SHA}`);
});
// --- BUD-12 management primitives (Phase 2) ---
test("deleteFile guards + calls deleteBlob with a delete auth", async () => {
const svc = new BlossomService();
const calls = [];
svc.loadSdk = async () => ({
createDeleteAuth: async (s, hash) => ({ kind: 24242, t: "delete", hash }),
deleteBlob: async (server, hash, opts) => {
if (typeof opts?.onAuth === "function") await opts.onAuth();
calls.push({ server, hash });
return true;
},
});
await assert.rejects(() => svc.deleteFile({ sha256: SHA, signer }), /server and a sha256/);
await assert.rejects(() => svc.deleteFile({ server: "https://a", signer }), /server and a sha256/);
await assert.rejects(
() => svc.deleteFile({ server: "https://a", sha256: SHA, signer: null }),
/requires a signer/,
);
const ok = await svc.deleteFile({ server: "https://a", sha256: SHA, signer });
assert.equal(ok, true);
assert.deepEqual(calls[0], { server: "https://a", hash: SHA });
});
test("blobSha256FromUrl extracts the 64-hex blob id (or '' for non-blobs)", () => {
const sha = "a".repeat(64);
assert.equal(blobSha256FromUrl(`https://npub1x.blossom.band/${sha}.mp4`), sha);
assert.equal(blobSha256FromUrl(`https://blossom.band/${sha}`), sha);
assert.equal(blobSha256FromUrl(`https://cdn.example/u/2026/clip.mp4`), "", "not a blob path");
assert.equal(blobSha256FromUrl("not a url"), "");
assert.equal(blobSha256FromUrl(""), "");
});
test("deleteByUrl maps a Blossom URL to its blob and deletes it on every server", async () => {
const svc = new BlossomService();
const calls = [];
svc.loadSdk = async () => ({
createDeleteAuth: async () => ({ kind: 24242, t: "delete" }),
deleteBlob: async (server, hash) => {
calls.push({ server, hash });
return true;
},
});
const sha = "b".repeat(64);
const out = await svc.deleteByUrl({
url: `https://blossom.band/${sha}.mp4`,
servers: ["https://a", "https://b"],
signer,
});
assert.equal(out.sha256, sha);
assert.deepEqual(out.deleted.sort(), ["https://a", "https://b"]);
assert.deepEqual(calls.map((c) => c.hash), [sha, sha]);
});
test("deleteByUrl falls back to the URL's own origin when no servers are given", async () => {
const svc = new BlossomService();
const calls = [];
svc.loadSdk = async () => ({
createDeleteAuth: async () => ({}),
deleteBlob: async (server, hash) => {
calls.push({ server, hash });
return true;
},
});
const sha = "d".repeat(64);
const out = await svc.deleteByUrl({
url: `https://npub1x.blossom.band/${sha}.mp4`,
signer,
});
assert.equal(out.sha256, sha);
assert.deepEqual(out.deleted, ["https://npub1x.blossom.band"], "deleted from the URL origin");
assert.equal(calls[0].server, "https://npub1x.blossom.band");
});
test("deleteByUrl is a no-op for a non-Blossom URL", async () => {
const svc = new BlossomService();
let called = false;
svc.loadSdk = async () => ({
createDeleteAuth: async () => ({}),
deleteBlob: async () => {
called = true;
},
});
const out = await svc.deleteByUrl({
url: "https://cdn.example/video.mp4",
servers: ["https://a"],
signer,
});
assert.equal(out.sha256, "");
assert.equal(called, false, "no delete attempted for a non-blob URL");
});
test("deleteByUrl reports per-server failures without throwing", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createDeleteAuth: async () => ({}),
deleteBlob: async (server) => {
if (server === "https://a") throw new Error("403 forbidden");
return true;
},
});
const sha = "c".repeat(64);
const out = await svc.deleteByUrl({
url: `https://x/${sha}`,
servers: ["https://a", "https://b"],
signer,
});
assert.deepEqual(out.deleted, ["https://b"]);
assert.equal(out.failed.length, 1);
assert.match(out.failed[0].error, /403/);
});
test("serversFromServerListEvent reads + de-dupes the BUD-03 server tags", () => {
const event = {
kind: 10063,
tags: [
["server", " https://a "],
["server", "https://a"],
["server", "https://b"],
["client", "x"],
["server", ""],
],
};
assert.deepEqual(serversFromServerListEvent(event), ["https://a", "https://b"]);
assert.deepEqual(serversFromServerListEvent(null), []);
});
test("listFiles returns the server's blob descriptors for a pubkey", async () => {
const svc = new BlossomService();
svc.loadSdk = async () => ({
createListAuth: async () => ({ kind: 24242, t: "list" }),
listBlobs: async (server, pubkey) => [
{ url: `${server}/${SHA}`, sha256: SHA, size: 1, type: "video/mp4" },
],
});
await assert.rejects(() => svc.listFiles({ pubkey: "pk" }), /server and a pubkey/);
const blobs = await svc.listFiles({ server: "https://a", pubkey: "f".repeat(64), signer });
assert.equal(blobs.length, 1);
assert.equal(blobs[0].sha256, SHA);
});