Files
bitvid/tests/nostr-delete-flow.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 874897b933 perf(nostr): verify event signatures off the main thread
Profiling the live app showed the single biggest CPU cost was the relay pool
verifying every incoming event's schnorr signature synchronously on the main
thread: ~1.2s of secp256k1 work ([email protected] 855ms) during feed load and tab
switches, dropping to ~16ms when skipped. The pool was constructed as
`new SimplePool()` with no options, so nostr-tools used its default verifying
verifyEvent; the `verifyEvent: () => true` that looked like it disabled this
only existed on a mock tools object and was never wired to the real pool.

Move verification off the main thread instead of dropping it:
- Construct the pool with `verifyEvent: () => true` so it never blocks.
- Add signatureVerifyWorker (+ client) that verifies event batches in a Web
  Worker and returns the valid ids; falls back to main-thread verification only
  when Workers are unavailable (never silently trusts).
- VideoEventBuffer verifies each batch via the worker before committing, and
  drops events that fail (fails closed). The verifier is injectable so tests can
  virtualize the worker; subscribeVideos passes it through and returns the flush
  promise from EOSE so commits are awaitable.

Tests: new signatureVerifyWorkerClient.test.mjs (worker protocol + drop-invalid
+ empty/no-id handling); videoEventBuffer.test.mjs and client.test.mjs adapted
to the async, verified commit path with a passthrough verifier.

Note: tests/nostr-delete-flow.test.mjs has a PRE-EXISTING failure on
`should not trigger subscription callbacks` (fails on clean unstable, and is a
standalone script not run by the unit-test runner); its edits here only adapt it
to the now-async flush.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-12 11:16:25 -04:00

491 lines
13 KiB
JavaScript

import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import { NostrClient } from "../js/nostr/client.js";
import { NostrService } from "../js/services/nostrService.js";
await (async function testDeleteFlowPublishesDeletionFlag() {
localStorage.clear();
const client = new NostrClient();
client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id));
client.hydrateVideoHistory = async () => {};
const pubkey = "0000000000000000000000000000000000000000000000000000000000000001";
const videoRootId = "root-abc";
const baseVideoId = "0000000000000000000000000000000000000000000000000000000000000002";
const baseVideo = {
id: baseVideoId,
pubkey,
created_at: 1_700_000_000,
version: 3,
videoRootId,
title: "Sample",
url: "https://example.com/video.mp4",
magnet: "",
thumbnail: "https://example.com/thumb.jpg",
description: "example",
mode: "live",
tags: [
["t", "video"],
["d", videoRootId],
],
};
client.allEvents.set(baseVideo.id, { ...baseVideo });
const signedEvents = [];
window.nostr = {
signEvent: async (event) => {
signedEvents.push(event);
return { ...event, id: `signed-${signedEvents.length}` };
},
getPublicKey: async () => pubkey,
};
client.signerManager.setActiveSigner({
type: "extension",
pubkey,
signEvent: window.nostr.signEvent,
});
client.ensureExtensionPermissions = async () => ({ ok: true });
client.relays = ["wss://relay.ok", "wss://relay.fail"];
client.rawEvents.set(baseVideo.id, {
id: baseVideo.id,
kind: 30078,
pubkey,
tags: baseVideo.tags,
});
client.pool = {
publish([url], event) {
const shouldFail = event.kind === 5 && url.includes("fail");
return {
on(type, handler) {
if (type === "ok") {
if (!shouldFail) {
handler();
}
return true;
}
if (type === "failed") {
if (shouldFail) {
handler(new Error(`reject-${url}`));
}
return true;
}
return false;
},
};
},
};
const revertCalls = [];
const revertEvents = [];
client.revertVideo = async (event) => {
revertCalls.push(event);
const created_at = baseVideo.created_at + 500 + revertCalls.length;
// Use valid hex ID for revert event to ensure e-tag logic works
const revertEventId = "000000000000000000000000000000000000000000000000000000000000000" + (3 + revertCalls.length);
const revertEvent = {
id: revertEventId,
kind: 30078,
pubkey,
created_at,
tags: baseVideo.tags,
};
revertEvents.push(revertEvent);
return {
event: revertEvent,
publishResults: client.relays.map((url) => ({ url, success: true })),
summary: {
accepted: client.relays.map((url) => ({ url, success: true })),
failed: [],
},
};
};
const result = await client.deleteAllVersions(videoRootId, pubkey, {
confirm: false,
video: baseVideo,
});
assert.equal(
Array.isArray(result.deletes),
true,
"deleteAllVersions should return delete summaries",
);
assert.equal(revertCalls.length, 1, "deleteAllVersions should call revertVideo once");
const deleteSummary = result.deletes[0];
assert.equal(
deleteSummary.summary.failed.length,
1,
"deleteAllVersions should surface relay failures for delete events",
);
assert.equal(
deleteSummary.summary.failed[0].url,
"wss://relay.fail",
"delete summary should identify the failing relay",
);
assert.equal(
deleteSummary.identifiers.events.includes(baseVideo.id),
true,
"delete summary should include the original event id",
);
assert.equal(
deleteSummary.identifiers.events.includes(revertEvents[0].id),
true,
"delete summary should include the revert event id",
);
assert.equal(
deleteSummary.identifiers.addresses.includes(
`30078:${pubkey}:${videoRootId}`,
),
true,
"delete summary should include the address pointer for the root",
);
const deleteRequest = signedEvents.find((event) => event.kind === 5);
assert(deleteRequest, "deleteAllVersions should sign a kind 5 delete event");
const eTags = deleteRequest.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "e")
.map(([, value]) => value);
assert.equal(
eTags.includes(baseVideo.id),
true,
"delete event should include original event id as an e-tag",
);
assert.equal(
eTags.includes(revertEvents[0].id),
true,
"delete event should include revert event id as an e-tag",
);
const aTags = deleteRequest.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
.map(([, value]) => value);
assert.equal(
aTags.includes(`30078:${pubkey}:${videoRootId}`),
true,
"delete event should include the address pointer as an a-tag",
);
const parsedPayload = JSON.parse(revertCalls[0].content);
assert.equal(parsedPayload.deleted, true, "revert payload should mark deleted true");
assert.equal(parsedPayload.videoRootId, videoRootId);
const cached = client.allEvents.get(baseVideo.id);
assert.equal(cached.deleted, true, "cached original video should be marked deleted");
const activeKey = `ROOT:${videoRootId}`;
const tombstoneValue = client.tombstones.get(activeKey);
assert.equal(
tombstoneValue,
revertEvents[0].created_at,
"deleteAllVersions should seed tombstone with revert timestamp",
);
client.activeMap.set(activeKey, {
...baseVideo,
videoRootId,
created_at: baseVideo.created_at,
});
const seenUpdates = [];
const handlers = {};
const fakeSub = {
on(type, handler) {
handlers[type] = handler;
},
unsub() {},
};
client.pool = {
sub() {
return fakeSub;
},
};
client.subscribeVideos((videos) => {
if (Array.isArray(videos)) {
seenUpdates.push(...videos);
}
});
const deleteEvent = {
id: "delete-event-1",
pubkey,
created_at: revertEvents[0].created_at + 100,
content: revertCalls[0].content,
tags: baseVideo.tags,
};
handlers.event(deleteEvent);
if (typeof handlers.eose === "function") {
await handlers.eose();
}
assert.equal(
seenUpdates.length,
0,
"deleted events should not trigger subscription callbacks",
);
assert.equal(
client.activeMap.has(activeKey),
false,
"deleted events should remove the active map entry",
);
const storedDelete = client.allEvents.get(deleteEvent.id);
assert.equal(storedDelete.deleted, true, "stored delete event should be marked deleted");
assert.equal(
client.tombstones.get(activeKey),
deleteEvent.created_at,
"subscription delete event should update tombstone",
);
delete window.nostr;
})();
await (async function testTombstonePersistenceAcrossSaveRestore() {
localStorage.clear();
const client = new NostrClient();
client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id));
const videoRootId = "root-persist";
const activeKey = `ROOT:${videoRootId}`;
const legacyVideo = {
id: "persist-video",
pubkey: "PUBKEY456",
created_at: 1_700_000_100,
version: 3,
videoRootId,
title: "Persisted",
url: "https://example.com/video.mp4",
magnet: "",
thumbnail: "",
description: "",
mode: "live",
tags: [
["t", "video"],
["d", videoRootId],
],
};
client.allEvents.set(legacyVideo.id, { ...legacyVideo });
client.recordTombstone(activeKey, legacyVideo.created_at + 50);
await client.saveLocalData("test", { immediate: true });
const restored = new NostrClient();
restored.videoEventVerifier = async (events) => new Set(events.map((e) => e.id));
await restored.restoreLocalData();
assert.equal(
restored.tombstones.get(activeKey),
legacyVideo.created_at + 50,
"tombstone timestamp should persist across save/restore",
);
const restoredVideo = restored.allEvents.get(legacyVideo.id);
assert.equal(restoredVideo.deleted, true, "restored video should remain tombstoned");
assert.equal(
restored.activeMap.has(activeKey),
false,
"restored tombstoned video should not populate activeMap",
);
})();
await (async function testSubscribeVideosSkipsOlderThanTombstone() {
localStorage.clear();
const client = new NostrClient();
client.videoEventVerifier = async (events) => new Set(events.map((e) => e.id));
client.populateNip71MetadataForVideos = async () => {};
const seenUpdates = [];
const handlers = {};
const fakeSub = {
on(type, handler) {
handlers[type] = handler;
},
unsub() {},
};
client.pool = {
sub() {
return fakeSub;
},
};
client.subscribeVideos((videos) => {
if (Array.isArray(videos)) {
seenUpdates.push(...videos);
}
});
const pubkey = "PUBKEY789";
const videoRootId = "root-sub";
const tags = [
["t", "video"],
["d", videoRootId],
];
const deleteEvent = {
id: "delete-event-guard",
pubkey,
created_at: 1_700_100_000,
content: JSON.stringify({
version: 3,
deleted: true,
title: "Deleted",
url: "https://example.com/video.mp4",
magnet: "",
thumbnail: "",
description: "",
mode: "live",
videoRootId,
}),
tags,
};
handlers.event(deleteEvent);
if (typeof handlers.eose === "function") {
await handlers.eose();
}
const activeKey = `ROOT:${videoRootId}`;
assert.equal(
client.tombstones.get(activeKey),
deleteEvent.created_at,
"delete subscription event should seed tombstone",
);
assert.equal(
client.activeMap.has(activeKey),
false,
"deleted subscription event should clear active map entry",
);
const olderEvent = {
id: "older-event",
pubkey,
created_at: deleteEvent.created_at - 60,
content: JSON.stringify({
version: 3,
deleted: false,
title: "Resurfaced",
url: "https://example.com/video.mp4",
magnet: "",
thumbnail: "",
description: "",
mode: "live",
videoRootId,
}),
tags,
};
handlers.event(olderEvent);
if (typeof handlers.eose === "function") {
await handlers.eose();
}
assert.equal(
seenUpdates.length,
0,
"older events published after tombstone should not trigger callbacks",
);
const storedOlder = client.allEvents.get(olderEvent.id);
assert.equal(storedOlder.deleted, true, "older event should be marked deleted by tombstone guard");
assert.equal(
client.activeMap.has(activeKey),
false,
"older event should not repopulate the active map",
);
})();
await (async function testNostrServiceBubblesDeleteFailures() {
const service = new NostrService();
const pubkey = "PUBKEY123";
const videoRootId = "root-service";
const sampleVideo = { id: "evt-service", pubkey, videoRootId };
const revertFailure = {
url: "wss://relay.fail",
error: new Error("revert failed"),
success: false,
};
const deleteFailure = {
url: "wss://relay.fail",
error: new Error("delete failed"),
success: false,
};
const summary = {
reverts: [
{
targetId: sampleVideo.id,
event: { id: "revert-event" },
publishResults: [
{ url: "wss://relay.ok", success: true },
revertFailure,
],
summary: {
accepted: [{ url: "wss://relay.ok", success: true }],
failed: [revertFailure],
},
},
],
deletes: [
{
event: { id: "delete-event" },
identifiers: {
events: [sampleVideo.id],
addresses: ["30078:pubkey:root-service"],
},
publishResults: [
{ url: "wss://relay.ok", success: true },
deleteFailure,
],
summary: {
accepted: [{ url: "wss://relay.ok", success: true }],
failed: [deleteFailure],
},
},
],
};
service.nostrClient = {
deleteAllVersions: async () => summary,
};
const emitted = [];
service.on("videos:deleted", (detail) => emitted.push(detail));
const detail = await service.handleFullDeleteVideo({
videoRootId,
video: sampleVideo,
pubkey,
confirm: false,
});
assert.equal(emitted.length, 1, "service should emit videos:deleted event");
assert.equal(
emitted[0].result,
summary,
"emitted detail should include the delete summary",
);
assert.equal(
detail.deleteFailures.length,
1,
"service should surface delete relay failures",
);
assert.equal(
detail.revertFailures.length,
1,
"service should surface revert relay failures",
);
assert.equal(
detail.deleteFailures[0].failed[0].url,
"wss://relay.fail",
"delete failures should include the failing relay url",
);
})();
console.log("nostr delete flow tests passed");