mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
fix(nip71): keep upload mirrors in sync
This commit is contained in:
1 parent
f0a3ede3e5
commit
e8e2b04de7
12 files changed
+325
-82
No files matched your search
@@ -85,14 +85,14 @@ test("resolveDeleteSync: attempts teardown whenever the feature is on, regardles
|
||||
assert.deepEqual(resolveDeleteSync({ featureOn: false, enabled: false }), { action: "none" });
|
||||
});
|
||||
|
||||
test("auto-share preference persists per pubkey, off by default", () => {
|
||||
test("auto-share preference is on by default and supports a durable opt-out", () => {
|
||||
localStorage.clear();
|
||||
assert.equal(isAutoShareEnabled(PK), false);
|
||||
setAutoShareEnabled(PK, true);
|
||||
assert.equal(isAutoShareEnabled(PK), true);
|
||||
assert.equal(isAutoShareEnabled("c".repeat(64)), false, "scoped per pubkey");
|
||||
setAutoShareEnabled(PK, false);
|
||||
assert.equal(isAutoShareEnabled(PK), false);
|
||||
assert.equal(isAutoShareEnabled("c".repeat(64)), true, "default is scoped per pubkey");
|
||||
setAutoShareEnabled(PK, true);
|
||||
assert.equal(isAutoShareEnabled(PK), true);
|
||||
});
|
||||
|
||||
test("resolvePublishSync: auto-mirror a new video only when auto-share on AND eligible", () => {
|
||||
|
||||
@@ -106,11 +106,11 @@ test("publish is unavailable without a signer / pubkey", async () => {
|
||||
assert.equal(result.error, "unavailable");
|
||||
});
|
||||
|
||||
test("remove() publishes a NIP-09 delete (both addressable kinds) AND a tombstone", async () => {
|
||||
test("remove() publishes a NIP-09 delete and tombstones both addressable kinds", async () => {
|
||||
const { service, relay } = makeService();
|
||||
const result = await service.remove(baseVideo());
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(relay.events.length, 2, "delete + empty-replace tombstone");
|
||||
assert.equal(relay.events.length, 3, "delete + empty replacements for both kinds");
|
||||
|
||||
const del = relay.events.find((e) => e.kind === 5);
|
||||
assert.ok(del, "must publish a NIP-09 kind-5 delete");
|
||||
@@ -122,21 +122,19 @@ test("remove() publishes a NIP-09 delete (both addressable kinds) AND a tombston
|
||||
const kTags = del.tags.filter((t) => t[0] === "k").map((t) => t[1]).sort();
|
||||
assert.deepEqual(kTags, ["34235", "34236"]);
|
||||
|
||||
const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236);
|
||||
assert.ok(tomb, "must publish an empty-replace tombstone");
|
||||
assert.equal(tomb.tags.find((t) => t[0] === "d")[1], "root-1", "same d-tag");
|
||||
assert.equal(
|
||||
tomb.tags.some((t) => t[0] === "imeta"),
|
||||
false,
|
||||
"tombstone has no playable imeta",
|
||||
);
|
||||
const tombs = relay.events.filter((e) => e.kind === 34235 || e.kind === 34236);
|
||||
assert.deepEqual(tombs.map((event) => event.kind).sort(), [34235, 34236]);
|
||||
for (const tomb of tombs) {
|
||||
assert.equal(tomb.tags.find((t) => t[0] === "d")[1], "root-1", "same d-tag");
|
||||
assert.equal(tomb.tags.some((t) => t[0] === "imeta"), false, "not playable");
|
||||
}
|
||||
});
|
||||
|
||||
test("remove() tombstone uses the short kind for portrait videos", async () => {
|
||||
const { service, relay } = makeService();
|
||||
await service.remove(baseVideo({ width: 1080, height: 1920 }));
|
||||
const tomb = relay.events.find((e) => e.kind === 34235 || e.kind === 34236);
|
||||
assert.equal(tomb.kind, 34236, "portrait => 34236 tombstone");
|
||||
const tombs = relay.events.filter((e) => e.kind === 34235 || e.kind === 34236);
|
||||
assert.deepEqual(tombs.map((event) => event.kind).sort(), [34235, 34236]);
|
||||
});
|
||||
|
||||
test("remove() is unavailable without a signer", async () => {
|
||||
@@ -150,7 +148,7 @@ test("remove() is unavailable without a signer", async () => {
|
||||
// (34236 short for portrait, else 34235), but (kind,pubkey,d) is the addressable
|
||||
// identity — so a kind flip between mirror attempts produced a DUPLICATE in NIP-71
|
||||
// clients. publish() must reuse the kind of any existing mirror instead.
|
||||
function makeServiceWithExisting(existing) {
|
||||
function makeServiceWithExisting(existing, { now = () => Date.now() } = {}) {
|
||||
const relay = makeRelay();
|
||||
const signer = { signEvent: async (tpl) => ({ ...tpl, id: "id", sig: "sig" }) };
|
||||
const service = createNip71MirrorService({
|
||||
@@ -163,6 +161,7 @@ function makeServiceWithExisting(existing) {
|
||||
signEvent: signer.signEvent,
|
||||
allowNsfw: () => false,
|
||||
fetchExistingMirrors: async () => existing,
|
||||
now,
|
||||
});
|
||||
return { service, relay };
|
||||
}
|
||||
@@ -208,6 +207,48 @@ test("an explicit options.short override is respected over any existing mirror",
|
||||
assert.equal(relay.events[0].kind, 34235, "explicit override wins");
|
||||
});
|
||||
|
||||
test("re-mirror advances past an existing addressable timestamp", async () => {
|
||||
const { service, relay } = makeServiceWithExisting(
|
||||
[{ kind: 34235, created_at: 500 }],
|
||||
{ now: () => 500_000 },
|
||||
);
|
||||
const result = await service.publish(baseVideo());
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(relay.events[0].created_at, 501, "mirror replacement must win its coordinate");
|
||||
});
|
||||
|
||||
test("remove advances its delete and tombstones past an existing mirror", async () => {
|
||||
const { service, relay } = makeServiceWithExisting(
|
||||
[{ kind: 34235, created_at: 500 }],
|
||||
{ now: () => 500_000 },
|
||||
);
|
||||
const result = await service.remove(baseVideo());
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(relay.events.map((event) => event.created_at), [501, 501, 501]);
|
||||
});
|
||||
|
||||
test("findMirrors resolves a library in one batched relay lookup", async () => {
|
||||
let batchCalls = 0;
|
||||
const service = createNip71MirrorService({
|
||||
getActivePubkey: () => PUBKEY,
|
||||
fetchMirrorsByRoots: async ({ roots }) => {
|
||||
batchCalls += 1;
|
||||
assert.deepEqual(roots.sort(), ["root-a", "root-b"]);
|
||||
return [
|
||||
{ root: "root-a", kind: 34235, created_at: 10 },
|
||||
{ root: "root-b", kind: 34236, created_at: 11 },
|
||||
];
|
||||
},
|
||||
});
|
||||
const states = await service.findMirrors([
|
||||
{ videoRootId: "root-a" },
|
||||
{ videoRootId: "root-b" },
|
||||
]);
|
||||
assert.equal(batchCalls, 1);
|
||||
assert.deepEqual(states.get("root-a"), { mirrored: true, kinds: [34235], duplicate: false });
|
||||
assert.deepEqual(states.get("root-b"), { mirrored: true, kinds: [34236], duplicate: false });
|
||||
});
|
||||
|
||||
test("findMirror derives mirror state from relays (truth), flagging duplicates", async () => {
|
||||
const dup = makeServiceWithExisting([{ kind: 34235 }, { kind: 34236 }]).service;
|
||||
assert.deepEqual(await dup.findMirror(baseVideo()), {
|
||||
|
||||
@@ -6,10 +6,16 @@ import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
import {
|
||||
syncNip71MirrorAfterEdit,
|
||||
syncNip71MirrorAfterPublish,
|
||||
syncNip71MirrorAfterDelete,
|
||||
initNip71MirrorSync,
|
||||
} from "../js/services/nip71MirrorSync.js";
|
||||
import { isMirrorEnabled, setMirrorEnabled } from "../js/services/nip71MirrorFlags.js";
|
||||
import {
|
||||
isMirrorEnabled,
|
||||
setMirrorEnabled,
|
||||
setAutoShareEnabled,
|
||||
} from "../js/services/nip71MirrorFlags.js";
|
||||
import { nip71MirrorService } from "../js/services/nip71MirrorService.js";
|
||||
|
||||
const PK = "a".repeat(64);
|
||||
@@ -63,6 +69,102 @@ test("delete sync never throws when teardown fails (best-effort)", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("editing a mirrored video uses the original root when the form omits it", async () => {
|
||||
localStorage.clear();
|
||||
setMirrorEnabled(PK, "root-edit", true);
|
||||
const canMirror = mock.method(nip71MirrorService, "canMirror", () => ({ ok: true }));
|
||||
const publishSpy = mock.method(nip71MirrorService, "publish", async () => ({ ok: true }));
|
||||
try {
|
||||
await syncNip71MirrorAfterEdit({
|
||||
updatedData: { title: "Edited", url: "https://cdn.example/video.mp4" },
|
||||
originalEvent: { videoRootId: "root-edit" },
|
||||
pubkey: PK,
|
||||
});
|
||||
assert.equal(publishSpy.mock.callCount(), 1, "mirror is republished after a normal edit");
|
||||
assert.equal(publishSpy.mock.calls[0].arguments[0].videoRootId, "root-edit");
|
||||
} finally {
|
||||
canMirror.mock.restore();
|
||||
publishSpy.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("failed edit-time unshare keeps the flag so a later edit retries cleanup", async () => {
|
||||
localStorage.clear();
|
||||
setMirrorEnabled(PK, "root-retry", true);
|
||||
const canMirror = mock.method(nip71MirrorService, "canMirror", () => ({ ok: false, reason: "private" }));
|
||||
const removeSpy = mock.method(nip71MirrorService, "remove", async () => ({ ok: false }));
|
||||
try {
|
||||
await syncNip71MirrorAfterEdit({
|
||||
updatedData: { isPrivate: true },
|
||||
originalEvent: { videoRootId: "root-retry" },
|
||||
pubkey: PK,
|
||||
});
|
||||
assert.equal(removeSpy.mock.callCount(), 1);
|
||||
assert.equal(isMirrorEnabled(PK, "root-retry"), true, "failed cleanup stays retryable");
|
||||
} finally {
|
||||
canMirror.mock.restore();
|
||||
removeSpy.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a per-upload opt-out prevents the default NIP-71 mirror", async () => {
|
||||
localStorage.clear();
|
||||
setAutoShareEnabled(PK, true);
|
||||
const publishSpy = mock.method(nip71MirrorService, "publish", async () => ({ ok: true }));
|
||||
try {
|
||||
await syncNip71MirrorAfterPublish({
|
||||
pubkey: PK,
|
||||
payload: { mirrorNip71: false },
|
||||
result: {
|
||||
legacy: {
|
||||
id: "event-id",
|
||||
pubkey: PK,
|
||||
created_at: 1,
|
||||
tags: [["d", "root-opt-out"]],
|
||||
content: JSON.stringify({
|
||||
videoRootId: "root-opt-out",
|
||||
title: "No mirror",
|
||||
url: "https://cdn.example/video.mp4",
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(publishSpy.mock.callCount(), 0);
|
||||
} finally {
|
||||
publishSpy.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new eligible upload mirrors by default", async () => {
|
||||
localStorage.clear();
|
||||
const canMirror = mock.method(nip71MirrorService, "canMirror", () => ({ ok: true }));
|
||||
const publishSpy = mock.method(nip71MirrorService, "publish", async () => ({ ok: true }));
|
||||
try {
|
||||
await syncNip71MirrorAfterPublish({
|
||||
pubkey: PK,
|
||||
payload: { mirrorNip71: true },
|
||||
result: {
|
||||
legacy: {
|
||||
id: "event-id-default",
|
||||
pubkey: PK,
|
||||
created_at: 1,
|
||||
tags: [["d", "root-default"]],
|
||||
content: JSON.stringify({
|
||||
videoRootId: "root-default",
|
||||
title: "Default mirror",
|
||||
url: "https://cdn.example/video.mp4",
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(publishSpy.mock.callCount(), 1);
|
||||
assert.equal(isMirrorEnabled(PK, "root-default"), true);
|
||||
} finally {
|
||||
canMirror.mock.restore();
|
||||
publishSpy.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("initNip71MirrorSync subscribes to edit+delete events, idempotently", () => {
|
||||
const events = {};
|
||||
const svc = {
|
||||
|
||||
@@ -28,6 +28,22 @@ test("normalizes minimal payload with hosted URL", () => {
|
||||
assert.equal(payload.legacyFormData.enableComments, true);
|
||||
assert.equal(payload.legacyFormData.isNsfw, false);
|
||||
assert.equal(payload.legacyFormData.isForKids, false);
|
||||
assert.equal(payload.mirrorNip71, true, "public uploads mirror by default");
|
||||
});
|
||||
|
||||
test("preserves an explicit per-upload NIP-71 mirror opt-out outside canonical content", () => {
|
||||
const { payload, errors } = normalizeVideoNotePayload({
|
||||
title: "Private distribution choice",
|
||||
url: "https://cdn.example.com/video.mp4",
|
||||
mirrorNip71: false,
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(payload.mirrorNip71, false);
|
||||
assert.equal(
|
||||
Object.hasOwn(payload.legacyFormData, "mirrorNip71"),
|
||||
false,
|
||||
"opt-out does not leak into the canonical 30078 payload",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes mode and boolean flags", () => {
|
||||
|
||||
Reference in new issue
Block a user