storage-cors-3: preserve newer live versions on late deletion markers

This commit is contained in:
thePR0M3TH3AN committed 2026-10-22 10:18:00 -04:00
1 parent 89c5494aaa
commit ed5fb64991
5 files changed
+162 -8

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { tombstoneFixture, videoEvent, ids } from './test-helpers/storage-tombstone-fixture.mjs';
// Scenarios and deterministic IO boundaries: docs/testing/storage-cors-3-integrity.md.
for (const order of ['newer-first', 'delete-first', 'same-batch']) {
test(`buffer preserves strictly newer active content: ${order}`, async t => {
const f = tombstoneFixture(t); await f.load();
const newer = videoEvent('newer', 300);
const deletion = videoEvent('deletion', 200, { deleted: true });
if (order === 'same-batch') await f.ingest(newer, deletion);
else if (order === 'newer-first') { await f.ingest(newer); await f.ingest(deletion); }
else { await f.ingest(deletion); await f.ingest(newer); }
await f.ingest(videoEvent('unrelated', 100, { root: 'other', pubkey: 'b'.repeat(64) }));
assert.deepEqual(ids(f.client.getActiveVideos()), ['newer', 'unrelated']);
assert.equal(f.client.allEvents.get('newer').deleted, false);
assert.equal(f.client.tombstones.get('ROOT:target'), 200);
// Replayed markers must not erase a newer version either.
await f.ingest(deletion);
assert.deepEqual(ids(f.client.getActiveVideos()), ['newer', 'unrelated']);
});
}
test('newer/equal tombstone removes older versions, late zombies stay absent, newer content may return', async t => {
const f = tombstoneFixture(t); await f.load();
await f.ingest(videoEvent('first', 100), videoEvent('edit', 150), videoEvent('other', 100, { root: 'other' }));
await f.ingest(videoEvent('delete', 150, { deleted: true }));
assert.deepEqual(ids(f.client.getActiveVideos()), ['other']);
assert.equal(f.client.allEvents.get('first').deleted, true);
assert.equal(f.client.allEvents.get('edit').deleted, true);
await f.ingest(videoEvent('late', 120));
assert.equal(f.client.allEvents.get('late').deleted, true);
assert.deepEqual(ids(f.client.getActiveVideos()), ['other']);
await f.ingest(videoEvent('fresh', 151));
assert.deepEqual(ids(f.client.getActiveVideos()), ['fresh', 'other']);
});
test('legacy d-tag tombstones retain authoritative author scoping', async t => {
const f = tombstoneFixture(t); await f.load();
await f.ingest(videoEvent('target-legacy', 100, { root: `LEGACY:${'a'.repeat(64)}:same`, dTag: 'same' }), videoEvent('other-author', 100, { root: `LEGACY:${'b'.repeat(64)}:same`, dTag: 'same', pubkey: 'b'.repeat(64) }));
await f.ingest(videoEvent('delete-legacy', 200, { root: `LEGACY:${'a'.repeat(64)}:same`, dTag: 'same', deleted: true }));
assert.deepEqual(ids(f.client.getActiveVideos()), ['other-author']);
await f.ingest(videoEvent('target-late', 90, { root: `LEGACY:${'a'.repeat(64)}:same`, dTag: 'same' }));
assert.deepEqual(ids(f.client.getActiveVideos()), ['other-author']);
assert.equal(f.client.allEvents.get('target-late').deleted, true);
});
@@ -0,0 +1,64 @@
import './setup-localstorage.mjs';
import assert from 'node:assert/strict';
import { NostrClient } from '../../js/nostr/client.js';
import { NostrService } from '../../js/services/nostrService.js';
import { getVideosMap, setVideosMap, getVideoSubscription, setVideoSubscription } from '../../js/state/appState.js';
export const filteredStorageKey = 'bitvid:filtered-videos:v1';
export const author = 'a'.repeat(64);
export function videoEvent(id, created_at, { root = 'target', deleted = false, pubkey = author, dTag = root, ...payload } = {}) {
return {
id, kind: 30078, pubkey, created_at,
tags: [['t', 'video'], ['d', dTag]],
content: JSON.stringify({ version: 3, title: id, url: 'https://example.invalid/video.mp4', mode: 'live', videoRootId: root, deleted, ...payload }),
};
}
export const ids = videos => videos.map(video => video.id).sort();
export function tombstoneFixture(t, { persisted = [] } = {}) {
const previous = { map: getVideosMap(), sub: getVideoSubscription(), storage: localStorage.getItem(filteredStorageKey) };
setVideosMap(new Map()); setVideoSubscription(null);
localStorage.setItem(filteredStorageKey, JSON.stringify(persisted));
let networkAttempts = 0;
const oldFetch = globalThis.fetch;
const oldSocket = globalThis.WebSocket;
globalThis.fetch = () => { networkAttempts++; throw new Error('Unexpected network'); };
globalThis.WebSocket = class { constructor() { networkAttempts++; throw new Error('Unexpected socket'); } };
const client = new NostrClient();
// Virtualize unrelated metadata and IndexedDB IO, keeping all ingest,
// tombstone, active/history, service and filtered-localStorage logic real.
client.saveLocalData = () => {};
client.populateNip71MetadataForVideos = async () => {};
client.videoEventVerifier = async events => new Set(events.map(event => event.id));
const channels = [];
client._subscriptionManager = { subscribe(config) {
const channel = { ...config, closed: false };
channels.push(channel);
return { close() { channel.closed = true; } };
} };
const service = new NostrService();
service.nostrClient = client;
service.moderationService = null;
service.accessControl = { isHydrated: () => true, ensureReady: async () => {}, canAccess: video => video.title !== 'denied' };
const notifications = [];
const updates = [];
const off = service.on('videos:updated', detail => updates.push(structuredClone(detail)));
const load = (options = {}) => service.loadVideos({ onVideos: (videos, detail) => notifications.push({ videos: structuredClone(videos), ...detail }), ...options });
t.after(() => {
service.clearVideoSubscription(); off();
setVideosMap(previous.map); setVideoSubscription(previous.sub);
if (previous.storage === null) localStorage.removeItem(filteredStorageKey);
else localStorage.setItem(filteredStorageKey, previous.storage);
globalThis.fetch = oldFetch; globalThis.WebSocket = oldSocket;
assert.equal(networkAttempts, 0, 'no real network attempted');
assert.ok(channels.every(channel => channel.closed), 'subscriptions naturally closed');
});
return { client, service, channels, notifications, updates, load,
async ingest(...events) {
const channel = channels.at(-1);
for (const event of events) channel.onEvent(event);
await channel.onEose();
},
persisted: () => JSON.parse(localStorage.getItem(filteredStorageKey) || '[]'),
};
}