mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
bitvid authors kind-30078 and MIRRORS to NIP-71; it never authors NIP-71 directly. Verified before removing: the My Videos mirror (buildNip71MirrorEvent) derives everything from the bitvid note itself (duration/dims are auto-captured at upload, contentWarning comes from the NSFW toggle, imeta is built from the video), and the only consumer of the form's NIP-71 metadata (publishNip71Video) is gated behind FEATURE_PUBLISH_NIP71: false — the fields fed a dead path and confused uploaders. Removed from the upload modal: "NIP-71 Technical" (content warning, duration, summary + Customize toggle) and the IMETA variant editor. Removed from the edit modal: kind (21/22) selector, published_at, alt, duration, content warning, summary, IMETA, text tracks, segments, participants, references. KEPT in both: hashtags (they feed hashtag feeds, the suggestion chips, and the mirror's t tags) and the web seeds / torrent (xs) inputs (bitvid-note fields, not NIP-71). All the JS plumbing is missing-DOM tolerant, so no wiring changes were needed to collect. Compatibility: existing videos' NIP-71 extras survive a save. EditModal keeps the video's full stored metadata (storedNip71Metadata) and merges the edited hashtags into it on submit instead of replacing it with the reduced form's empty output (t mirror key kept in sync). Also fixes a latent pre-existing bug the reduction exposed: nip71Edited compared the submit-side copy AFTER publishedAt normalization (string→timestamp, delete-empty) against an UN-normalized original, so any video with publishedAt made every save look "edited". Both sides now share normalizeCollectedNip71. Tests: +2 in edit-modal-submit-state (stored extras survive a hashtag edit; untouched save keeps nip71Edited false). Modal/payload/mirror suites green (39 + 7). Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
430 lines
13 KiB
JavaScript
430 lines
13 KiB
JavaScript
import test, { beforeEach, afterEach } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { JSDOM } from 'jsdom';
|
|
|
|
import { EditModal } from '../js/ui/components/EditModal.js';
|
|
|
|
const editModalHtml = await readFile(
|
|
new URL('../components/edit-video-modal.html', import.meta.url),
|
|
'utf8',
|
|
);
|
|
|
|
let dom;
|
|
let container;
|
|
|
|
beforeEach(() => {
|
|
dom = new JSDOM(
|
|
'<!DOCTYPE html><html><body><div id="modalContainer"></div></body></html>',
|
|
{
|
|
url: 'https://example.com',
|
|
pretendToBeVisual: true,
|
|
},
|
|
);
|
|
|
|
global.window = dom.window;
|
|
global.document = dom.window.document;
|
|
global.HTMLElement = dom.window.HTMLElement;
|
|
global.HTMLInputElement = dom.window.HTMLInputElement;
|
|
global.HTMLButtonElement = dom.window.HTMLButtonElement;
|
|
global.CustomEvent = dom.window.CustomEvent;
|
|
global.Event = dom.window.Event;
|
|
global.EventTarget = dom.window.EventTarget;
|
|
|
|
container = document.getElementById('modalContainer');
|
|
|
|
global.fetch = async (resource) => {
|
|
if (
|
|
typeof resource === 'string' &&
|
|
resource.includes('components/edit-video-modal.html')
|
|
) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => editModalHtml,
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unexpected fetch: ${resource}`);
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete global.fetch;
|
|
delete global.EventTarget;
|
|
delete global.Event;
|
|
delete global.CustomEvent;
|
|
delete global.HTMLButtonElement;
|
|
delete global.HTMLInputElement;
|
|
delete global.HTMLElement;
|
|
delete global.document;
|
|
delete global.window;
|
|
|
|
if (dom) {
|
|
dom.window.close();
|
|
dom = null;
|
|
}
|
|
container = null;
|
|
});
|
|
|
|
function createModal({
|
|
showError,
|
|
eventTarget = new EventTarget(),
|
|
} = {}) {
|
|
return new EditModal({
|
|
removeTrackingScripts: () => {},
|
|
setGlobalModalState: () => {},
|
|
showError: showError || (() => {}),
|
|
getMode: () => 'live',
|
|
eventTarget,
|
|
container,
|
|
});
|
|
}
|
|
|
|
test('ignores additional submissions while pending without spurious errors', async () => {
|
|
const errorMessages = [];
|
|
const modalEvents = new EventTarget();
|
|
const modal = createModal({
|
|
showError: (message) => {
|
|
if (message) {
|
|
errorMessages.push(message);
|
|
}
|
|
},
|
|
eventTarget: modalEvents,
|
|
});
|
|
|
|
await modal.load({ container });
|
|
|
|
const video = {
|
|
id: 'video1',
|
|
pubkey: 'pubkey1',
|
|
title: 'Sample video',
|
|
url: 'https://example.com/video.mp4',
|
|
thumbnail: '',
|
|
description: '',
|
|
enableComments: true,
|
|
isPrivate: false,
|
|
isNsfw: true,
|
|
isForKids: false,
|
|
};
|
|
|
|
await modal.open(video);
|
|
|
|
assert.ok(modal.submitButton, 'expected submit button to be cached');
|
|
assert.equal(modal.submitButton.disabled, false);
|
|
|
|
const submissions = [];
|
|
modal.addEventListener('video:edit-submit', (event) => {
|
|
submissions.push(event.detail);
|
|
});
|
|
|
|
modal.submit();
|
|
|
|
assert.equal(submissions.length, 1, 'first submit should emit once');
|
|
assert.equal(modal.pendingSubmit, true, 'pending flag should be set after submit');
|
|
assert.equal(
|
|
modal.pendingSubmitVideo?.id,
|
|
video.id,
|
|
'pending video context should match the edited video',
|
|
);
|
|
assert.equal(modal.submitButton.disabled, true, 'button should disable while pending');
|
|
|
|
const firstSubmission = submissions[0];
|
|
assert.ok(firstSubmission, 'expected first submission payload');
|
|
assert.equal(
|
|
firstSubmission.updatedData.isNsfw,
|
|
true,
|
|
'locked NSFW flag should persist in emitted payload',
|
|
);
|
|
assert.equal(
|
|
firstSubmission.updatedData.isForKids,
|
|
false,
|
|
'locked kids flag should persist in emitted payload',
|
|
);
|
|
|
|
modal.submit();
|
|
|
|
assert.equal(submissions.length, 1, 'duplicate submit should be ignored');
|
|
assert.equal(
|
|
errorMessages.includes('No video selected for editing.'),
|
|
false,
|
|
'should not surface missing video error during pending submit',
|
|
);
|
|
|
|
modal.setSubmitState({ pending: false });
|
|
|
|
assert.equal(modal.pendingSubmit, false, 'pending flag should reset after release');
|
|
assert.equal(modal.submitButton.disabled, false, 'button should re-enable after release');
|
|
|
|
const unlockButton = (selector) =>
|
|
modal.root.querySelector(selector)?.dispatchEvent(
|
|
new dom.window.Event('click', { bubbles: true }),
|
|
);
|
|
|
|
unlockButton('[data-edit-target="editVideoIsNsfw"]');
|
|
unlockButton('[data-edit-target="editVideoIsForKids"]');
|
|
|
|
const nsfwCheckbox = modal.fields.isNsfw;
|
|
const kidsCheckbox = modal.fields.isForKids;
|
|
|
|
assert.ok(nsfwCheckbox, 'expected nsfw checkbox to be cached');
|
|
assert.ok(kidsCheckbox, 'expected kids checkbox to be cached');
|
|
|
|
nsfwCheckbox.checked = false;
|
|
kidsCheckbox.checked = true;
|
|
|
|
modal.submit();
|
|
|
|
assert.equal(submissions.length, 2, 'unlocked submit should emit again');
|
|
const secondSubmission = submissions[1];
|
|
assert.ok(secondSubmission, 'expected second submission payload');
|
|
assert.equal(
|
|
secondSubmission.updatedData.isNsfw,
|
|
false,
|
|
'updated NSFW flag should reflect unlocked checkbox state',
|
|
);
|
|
assert.equal(
|
|
secondSubmission.updatedData.isForKids,
|
|
true,
|
|
'updated kids flag should reflect unlocked checkbox state',
|
|
);
|
|
|
|
modal.setSubmitState({ pending: false });
|
|
});
|
|
|
|
test('editing the magnet refreshes torrent hints when ws/xs remain locked', async () => {
|
|
const modal = createModal();
|
|
await modal.load({ container });
|
|
|
|
const originalMagnet =
|
|
'magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&ws=https://old.example/video.mp4&xs=https://old.example/video.torrent';
|
|
const video = {
|
|
id: 'video-magnet-locked',
|
|
pubkey: 'pubkey-magnet-locked',
|
|
title: 'Locked hints example',
|
|
url: 'https://example.com/locked.mp4',
|
|
magnet: originalMagnet,
|
|
ws: 'https://old.example/video.mp4',
|
|
xs: 'https://old.example/video.torrent',
|
|
};
|
|
|
|
await modal.open(video);
|
|
|
|
const submissions = [];
|
|
modal.addEventListener('video:edit-submit', (event) => {
|
|
submissions.push(event.detail);
|
|
});
|
|
|
|
const magnetButton = modal.root.querySelector(
|
|
'[data-edit-target="editVideoMagnet"]',
|
|
);
|
|
assert.ok(magnetButton, 'expected magnet unlock button to exist');
|
|
magnetButton.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
|
|
|
|
const magnetInput = modal.fields.magnet;
|
|
assert.ok(magnetInput, 'expected magnet input to exist');
|
|
magnetInput.value =
|
|
'magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&ws=https://new.example/video.mp4&xs=https://new.example/video.torrent';
|
|
|
|
modal.submit();
|
|
|
|
assert.equal(submissions.length, 1, 'should emit a single submission payload');
|
|
const submission = submissions[0];
|
|
assert.ok(submission, 'expected edit submission detail');
|
|
|
|
assert.equal(
|
|
submission.updatedData.wsEdited,
|
|
true,
|
|
'ws should be marked as edited when magnet changes',
|
|
);
|
|
assert.equal(
|
|
submission.updatedData.xsEdited,
|
|
true,
|
|
'xs should be marked as edited when magnet changes',
|
|
);
|
|
assert.equal(
|
|
submission.updatedData.ws,
|
|
'https://new.example/video.mp4',
|
|
'ws hint should refresh to the new magnet hint',
|
|
);
|
|
assert.equal(
|
|
submission.updatedData.xs,
|
|
'https://new.example/video.torrent',
|
|
'xs hint should refresh to the new magnet hint',
|
|
);
|
|
assert.ok(
|
|
submission.updatedData.magnet.includes(
|
|
'urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
),
|
|
'normalized magnet should contain the updated info hash',
|
|
);
|
|
assert.ok(
|
|
submission.updatedData.magnet.includes('ws=https://new.example/video.mp4'),
|
|
'normalized magnet should include the refreshed ws hint',
|
|
);
|
|
assert.ok(
|
|
submission.updatedData.magnet.includes(
|
|
'xs=https://new.example/video.torrent',
|
|
),
|
|
'normalized magnet should include the refreshed xs hint',
|
|
);
|
|
|
|
modal.setSubmitState({ pending: false });
|
|
});
|
|
|
|
test('does not show missing video error after modal closes', async () => {
|
|
const errorMessages = [];
|
|
const modal = createModal({
|
|
showError: (message) => {
|
|
if (message) {
|
|
errorMessages.push(message);
|
|
}
|
|
},
|
|
});
|
|
|
|
await modal.load({ container });
|
|
|
|
const video = {
|
|
id: 'video2',
|
|
pubkey: 'pubkey2',
|
|
title: 'Another video',
|
|
url: 'https://example.com/another.mp4',
|
|
};
|
|
|
|
await modal.open(video);
|
|
|
|
modal.submit();
|
|
|
|
assert.equal(errorMessages.includes('No video selected for editing.'), false);
|
|
|
|
modal.setSubmitState({ pending: false });
|
|
modal.close();
|
|
|
|
assert.equal(modal.isVisible, false, 'modal should be hidden after close');
|
|
|
|
modal.submit();
|
|
|
|
assert.equal(
|
|
errorMessages.includes('No video selected for editing.'),
|
|
false,
|
|
'should not surface missing video error when closed',
|
|
);
|
|
});
|
|
|
|
// NIP-71 extras must survive a save now that the edit form only exposes hashtags
|
|
// (the rest of the NIP-71 section was removed for a cleaner UI). A hashtag edit
|
|
// must merge into the video's stored metadata — never replace it with the reduced
|
|
// form's empty fields.
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-edit-nip71-preserved
|
|
// given: "a video whose stored nip71 carries imeta/textTracks/participants"
|
|
// when: "the user edits hashtags in the reduced form and submits"
|
|
// then: "updatedData.nip71 keeps the extras AND the new hashtags; nip71Edited reflects the hashtag change"
|
|
// observable_outcomes:
|
|
// - "imeta/textTracks/participants/publishedAt survive the save"
|
|
// - "hashtags (and a mirrored t key) update to the edited set"
|
|
// - "no hashtag change -> nip71Edited stays false"
|
|
// determinism_controls:
|
|
// - "JSDOM modal against the real component HTML; no network"
|
|
// anti_cheat_rationale:
|
|
// prevents: ["asserting the wiping (broken) behavior", "over-mocking internal logic"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
test('editing hashtags preserves stored NIP-71 extras instead of wiping them', async () => {
|
|
const modal = createModal({ eventTarget: new EventTarget() });
|
|
await modal.load({ container });
|
|
|
|
const storedNip71 = {
|
|
publishedAt: 1700000000,
|
|
imeta: [{ url: 'https://cdn.example.com/v.mp4', m: 'video/mp4' }],
|
|
textTracks: [{ url: 'https://cdn.example.com/v.vtt', type: 'captions' }],
|
|
participants: [{ pubkey: 'a'.repeat(64) }],
|
|
hashtags: ['old'],
|
|
t: ['old'],
|
|
};
|
|
|
|
const video = {
|
|
id: 'video-nip71',
|
|
pubkey: 'pubkey1',
|
|
title: 'Sample video',
|
|
url: 'https://example.com/video.mp4',
|
|
thumbnail: '',
|
|
description: '',
|
|
enableComments: true,
|
|
isPrivate: false,
|
|
isNsfw: false,
|
|
isForKids: false,
|
|
nip71: storedNip71,
|
|
};
|
|
|
|
await modal.open(video);
|
|
|
|
// Edit hashtags through the reduced form (same path as the UI's Add hashtag).
|
|
const entry = modal.nip71FormManager.addRepeaterEntry('edit', 't');
|
|
modal.nip71FormManager.setFieldValue(entry, 'value', 'newtag');
|
|
|
|
const submissions = [];
|
|
modal.addEventListener('video:edit-submit', (event) => {
|
|
submissions.push(event.detail);
|
|
});
|
|
|
|
await modal.submit();
|
|
|
|
assert.equal(submissions.length, 1, 'submit emitted');
|
|
const savedNip71 = submissions[0].updatedData.nip71;
|
|
assert.equal(submissions[0].updatedData.nip71Edited, true, 'hashtag change detected');
|
|
|
|
assert.deepEqual(savedNip71.imeta, storedNip71.imeta, 'imeta preserved');
|
|
assert.deepEqual(savedNip71.textTracks, storedNip71.textTracks, 'text tracks preserved');
|
|
assert.deepEqual(savedNip71.participants, storedNip71.participants, 'participants preserved');
|
|
assert.equal(savedNip71.publishedAt, storedNip71.publishedAt, 'publishedAt preserved');
|
|
assert.deepEqual(savedNip71.hashtags, ['old', 'newtag'], 'edited hashtags applied');
|
|
assert.deepEqual(savedNip71.t, ['old', 'newtag'], 'mirrored t key kept in sync');
|
|
});
|
|
|
|
test('submitting without touching hashtags leaves nip71Edited false and extras intact', async () => {
|
|
const modal = createModal({ eventTarget: new EventTarget() });
|
|
await modal.load({ container });
|
|
|
|
const storedNip71 = {
|
|
imeta: [{ url: 'https://cdn.example.com/v.mp4', m: 'video/mp4' }],
|
|
hashtags: ['keepme'],
|
|
};
|
|
|
|
const video = {
|
|
id: 'video-nip71-b',
|
|
pubkey: 'pubkey1',
|
|
title: 'Sample video',
|
|
url: 'https://example.com/video.mp4',
|
|
thumbnail: '',
|
|
description: '',
|
|
enableComments: true,
|
|
isPrivate: false,
|
|
isNsfw: false,
|
|
isForKids: false,
|
|
nip71: storedNip71,
|
|
};
|
|
|
|
await modal.open(video);
|
|
|
|
const submissions = [];
|
|
modal.addEventListener('video:edit-submit', (event) => {
|
|
submissions.push(event.detail);
|
|
});
|
|
|
|
await modal.submit();
|
|
|
|
assert.equal(submissions.length, 1);
|
|
assert.equal(submissions[0].updatedData.nip71Edited, false, 'no hashtag change');
|
|
assert.deepEqual(
|
|
submissions[0].updatedData.nip71.imeta,
|
|
storedNip71.imeta,
|
|
'extras intact on an untouched save',
|
|
);
|
|
assert.deepEqual(submissions[0].updatedData.nip71.hashtags, ['keepme']);
|
|
});
|