mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
443 lines
13 KiB
JavaScript
443 lines
13 KiB
JavaScript
import "../test-helpers/setup-localstorage.mjs";
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
buildWatchHistoryPayload,
|
|
createWatchHistoryManager,
|
|
fetchWatchHistory,
|
|
getWatchHistoryCacheTtlMs,
|
|
getWatchHistoryStorage,
|
|
} from "../../js/nostr/watchHistory.js";
|
|
import { WATCH_HISTORY_KIND } from "../../js/config.js";
|
|
|
|
const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v4";
|
|
|
|
function ensureLocalStorageCleared() {
|
|
if (typeof localStorage?.clear === "function") {
|
|
localStorage.clear();
|
|
}
|
|
}
|
|
|
|
test.beforeEach(() => {
|
|
ensureLocalStorageCleared();
|
|
});
|
|
|
|
test("buildWatchHistoryPayload enforces byte limits and records skipped entries", () => {
|
|
const month = "2025-01";
|
|
const maxBytes = 256;
|
|
const oversizedPointer = {
|
|
type: "e",
|
|
value: "huge-pointer",
|
|
metadata: { note: "x".repeat(400) },
|
|
};
|
|
const smallPointers = Array.from({ length: 5 }, (_, index) => ({
|
|
type: "e",
|
|
value: `pointer-${index + 1}`,
|
|
watchedAt: index + 1,
|
|
metadata: { note: "y".repeat(20) },
|
|
}));
|
|
const payloadItems = [oversizedPointer, ...smallPointers];
|
|
|
|
const { payload, skipped, included } = buildWatchHistoryPayload(
|
|
month,
|
|
payloadItems,
|
|
null,
|
|
maxBytes,
|
|
);
|
|
|
|
assert.equal(skipped.length, 1, "oversized entries should be skipped");
|
|
assert.equal(
|
|
skipped[0]?.value,
|
|
oversizedPointer.value,
|
|
"skipped list should capture the oversized pointer",
|
|
);
|
|
|
|
assert.equal(payload.month, month, "payload should record the requested month");
|
|
assert.equal(payload.version, 2, "payload should record the expected version");
|
|
assert.deepStrictEqual(
|
|
payload.items,
|
|
smallPointers.map((item) => item.value),
|
|
"all remaining pointers should be included in the payload",
|
|
);
|
|
assert.deepStrictEqual(
|
|
payload.watchedAt,
|
|
smallPointers.reduce((acc, pointer) => ({ ...acc, [pointer.value]: pointer.watchedAt }), {}),
|
|
"watchedAt map should mirror included timestamps",
|
|
);
|
|
assert.equal(
|
|
included.length,
|
|
smallPointers.length,
|
|
"included pointers should mirror the payload items",
|
|
);
|
|
const serializedLength = JSON.stringify(payload).length;
|
|
assert(
|
|
serializedLength <= maxBytes,
|
|
`payload should respect the configured max bytes (observed ${serializedLength})`,
|
|
);
|
|
});
|
|
|
|
test("getWatchHistoryStorage prunes entries that exceed the configured TTL", () => {
|
|
const manager = createWatchHistoryManager();
|
|
const ttl = getWatchHistoryCacheTtlMs(manager);
|
|
const originalNow = Date.now;
|
|
const baseNow = Date.now();
|
|
const freshActor = "1".repeat(64);
|
|
const staleActor = "2".repeat(64);
|
|
|
|
try {
|
|
Date.now = () => baseNow;
|
|
|
|
const storagePayload = {
|
|
version: 2,
|
|
actors: {
|
|
[freshActor]: {
|
|
actor: freshActor,
|
|
snapshotId: "snap-fresh",
|
|
fingerprint: "fingerprint-fresh",
|
|
savedAt: baseNow - (ttl - 1000),
|
|
items: [{ type: "e", value: "fresh-pointer", watchedAt: 123 }],
|
|
metadata: { status: "ok" },
|
|
},
|
|
[staleActor]: {
|
|
actor: staleActor,
|
|
snapshotId: "snap-stale",
|
|
fingerprint: "fingerprint-stale",
|
|
savedAt: baseNow - (ttl + 1000),
|
|
items: [{ type: "e", value: "stale-pointer", watchedAt: 456 }],
|
|
metadata: { status: "stale" },
|
|
},
|
|
},
|
|
};
|
|
localStorage.setItem(WATCH_HISTORY_STORAGE_KEY, JSON.stringify(storagePayload));
|
|
|
|
const storage = getWatchHistoryStorage(manager);
|
|
|
|
assert.deepStrictEqual(
|
|
Object.keys(storage.actors),
|
|
[freshActor],
|
|
"hydrated storage should drop actors whose savedAt exceeds the TTL",
|
|
);
|
|
|
|
const freshEntry = storage.actors[freshActor];
|
|
assert.ok(freshEntry, "fresh actor should remain present after hydration");
|
|
assert.equal(freshEntry.snapshotId, "snap-fresh");
|
|
assert.equal(freshEntry.fingerprint, "fingerprint-fresh");
|
|
assert.equal(freshEntry.items.length, 1);
|
|
assert.equal(freshEntry.items[0]?.value, "fresh-pointer");
|
|
assert.equal(freshEntry.metadata.status, "ok");
|
|
assert.strictEqual(
|
|
storage.actors[staleActor],
|
|
undefined,
|
|
"stale actor entry should be removed",
|
|
);
|
|
|
|
const persistedRaw = localStorage.getItem(WATCH_HISTORY_STORAGE_KEY);
|
|
assert.ok(persistedRaw, "storage rewrite should persist sanitized payload");
|
|
const persisted = JSON.parse(persistedRaw);
|
|
assert.deepStrictEqual(
|
|
Object.keys(persisted.actors),
|
|
[freshActor],
|
|
"persisted storage should match hydrated storage",
|
|
);
|
|
} finally {
|
|
Date.now = originalNow;
|
|
manager.clear();
|
|
}
|
|
});
|
|
|
|
test("fetchWatchHistory prefers decrypted chunk payloads when nip04 decrypt succeeds", async () => {
|
|
const actorHex = "3".repeat(64);
|
|
const snapshotId = "snap-success";
|
|
const chunkIdentifier = "chunk-success";
|
|
const ciphertext = "YmFzZTY0LWVuY29kZWQ=?iv=YWJjZGVm";
|
|
|
|
const pointerEvent = {
|
|
id: "pointer-success",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_000_000,
|
|
content: JSON.stringify({
|
|
version: 2,
|
|
snapshot: snapshotId,
|
|
items: [{ type: "e", value: "fallback-pointer", watchedAt: 1 }],
|
|
chunkIndex: 0,
|
|
totalChunks: 1,
|
|
}),
|
|
tags: [
|
|
["snapshot", snapshotId],
|
|
["a", `${WATCH_HISTORY_KIND}:${actorHex}:${chunkIdentifier}`],
|
|
],
|
|
};
|
|
|
|
const chunkEvent = {
|
|
id: "chunk-success",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_000_100,
|
|
content: ciphertext,
|
|
tags: [
|
|
["d", chunkIdentifier],
|
|
["encrypted", "nip04"],
|
|
["snapshot", snapshotId],
|
|
["chunk", "0", "1"],
|
|
],
|
|
};
|
|
|
|
const decryptCalls = [];
|
|
const signer = {
|
|
nip04Decrypt: async (pubkey, payload) => {
|
|
decryptCalls.push({ pubkey, payload });
|
|
assert.equal(pubkey, actorHex, "decrypt should target the normalized actor key");
|
|
assert.equal(payload, ciphertext, "decrypt should receive the chunk ciphertext");
|
|
return JSON.stringify({
|
|
version: 2,
|
|
snapshot: snapshotId,
|
|
chunkIndex: 0,
|
|
totalChunks: 1,
|
|
items: [{ type: "e", value: "decrypted-pointer", watchedAt: 9 }],
|
|
});
|
|
},
|
|
};
|
|
|
|
let listCall = 0;
|
|
const pool = {
|
|
async list() {
|
|
listCall += 1;
|
|
if (listCall === 1) {
|
|
return [pointerEvent];
|
|
}
|
|
return [chunkEvent];
|
|
},
|
|
};
|
|
|
|
const manager = createWatchHistoryManager({
|
|
getActivePubkey: () => actorHex,
|
|
resolveActiveSigner: () => signer,
|
|
shouldRequestExtensionPermissions: () => false,
|
|
getPool: () => pool,
|
|
getReadRelays: () => ["wss://relay.example"],
|
|
});
|
|
|
|
try {
|
|
const result = await fetchWatchHistory(manager, actorHex, { forceRefresh: true });
|
|
|
|
assert.equal(decryptCalls.length, 1, "decrypt should be attempted exactly once");
|
|
assert.deepStrictEqual(
|
|
result.items.map((item) => item.value),
|
|
["decrypted-pointer"],
|
|
"decrypted payload should replace fallback pointer items",
|
|
);
|
|
} finally {
|
|
manager.clear();
|
|
}
|
|
});
|
|
|
|
test("fetchWatchHistory falls back to pointer payload when nip04 decrypt fails", async () => {
|
|
const actorHex = "4".repeat(64);
|
|
const snapshotId = "snap-fallback";
|
|
const chunkIdentifier = "chunk-fallback";
|
|
const ciphertext = "ZmFpbC1kZWNyeXB0P2l2PWFiYw==";
|
|
|
|
const pointerEvent = {
|
|
id: "pointer-fallback",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_100_000,
|
|
content: JSON.stringify({
|
|
version: 2,
|
|
snapshot: snapshotId,
|
|
items: [{ type: "e", value: "pointer-from-snapshot", watchedAt: 5 }],
|
|
chunkIndex: 0,
|
|
totalChunks: 1,
|
|
}),
|
|
tags: [
|
|
["snapshot", snapshotId],
|
|
["a", `${WATCH_HISTORY_KIND}:${actorHex}:${chunkIdentifier}`],
|
|
],
|
|
};
|
|
|
|
const chunkEvent = {
|
|
id: "chunk-fallback",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_100_100,
|
|
content: ciphertext,
|
|
tags: [
|
|
["d", chunkIdentifier],
|
|
["encrypted", "nip04"],
|
|
["snapshot", snapshotId],
|
|
["chunk", "0", "1"],
|
|
],
|
|
};
|
|
|
|
let decryptCalls = 0;
|
|
const signer = {
|
|
nip04Decrypt: async () => {
|
|
decryptCalls += 1;
|
|
throw new Error("decrypt failed");
|
|
},
|
|
};
|
|
|
|
let listCall = 0;
|
|
const pool = {
|
|
async list() {
|
|
listCall += 1;
|
|
if (listCall === 1) {
|
|
return [pointerEvent];
|
|
}
|
|
return [chunkEvent];
|
|
},
|
|
};
|
|
|
|
const manager = createWatchHistoryManager({
|
|
getActivePubkey: () => actorHex,
|
|
resolveActiveSigner: () => signer,
|
|
shouldRequestExtensionPermissions: () => false,
|
|
getPool: () => pool,
|
|
getReadRelays: () => ["wss://relay.example"],
|
|
});
|
|
|
|
try {
|
|
const result = await fetchWatchHistory(manager, actorHex, { forceRefresh: true });
|
|
|
|
assert.equal(decryptCalls, 1, "decrypt should still be attempted once");
|
|
assert.deepStrictEqual(
|
|
result.items.map((item) => item.value),
|
|
["pointer-from-snapshot"],
|
|
"fallback pointer items should be used when decrypt fails",
|
|
);
|
|
} finally {
|
|
manager.clear();
|
|
}
|
|
});
|
|
|
|
test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer cannot encrypt", async () => {
|
|
const actorPubkey = "f".repeat(64);
|
|
const sessionPrivateKey = "a".repeat(64);
|
|
let encryptCalls = 0;
|
|
const toolkit = {
|
|
nip04: {
|
|
encrypt: async (_priv, _pub, plaintext) => {
|
|
encryptCalls += 1;
|
|
return `encrypted:${plaintext}`;
|
|
},
|
|
},
|
|
};
|
|
|
|
const manager = createWatchHistoryManager({
|
|
getActivePubkey: () => actorPubkey,
|
|
getSessionActor: () => ({ pubkey: actorPubkey, privateKey: sessionPrivateKey }),
|
|
ensureSessionActor: async () => actorPubkey,
|
|
resolveActiveSigner: () => null,
|
|
shouldRequestExtensionPermissions: () => false,
|
|
ensureExtensionPermissions: async () => ({ ok: true }),
|
|
ensureNostrTools: async () => toolkit,
|
|
getCachedNostrTools: () => toolkit,
|
|
signEventWithPrivateKey: (event) => ({
|
|
...event,
|
|
id: `signed-${event.kind}`,
|
|
sig: `sig-${event.kind}`,
|
|
}),
|
|
getReadRelays: () => ["wss://relay.integration"],
|
|
getWriteRelays: () => ["wss://relay.integration"],
|
|
getRelayFallback: () => ["wss://relay.integration"],
|
|
getPool: () => ({
|
|
publish: () => ({
|
|
on(eventName, handler) {
|
|
if (eventName === "ok") {
|
|
handler();
|
|
}
|
|
return this;
|
|
},
|
|
}),
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const result = await manager.publishSnapshot(
|
|
[{ type: "e", value: "pointer", watchedAt: 123 }],
|
|
{ actorPubkey, snapshotId: "snapshot-injected" },
|
|
);
|
|
|
|
assert.equal(
|
|
result.ok,
|
|
true,
|
|
"snapshot publish should succeed with injected toolkit helpers",
|
|
);
|
|
assert(
|
|
encryptCalls > 0,
|
|
"fallback nostr-tools encrypt helper should be invoked",
|
|
);
|
|
} finally {
|
|
manager.clear();
|
|
}
|
|
});
|
|
|
|
test("publishWatchHistorySnapshot caches successful snapshot results", async () => {
|
|
const actorPubkey = "c".repeat(64);
|
|
const sessionPrivateKey = "s".repeat(64);
|
|
const relayUrls = ["wss://relay.cache"];
|
|
const publishCalls = [];
|
|
|
|
const manager = createWatchHistoryManager({
|
|
getActivePubkey: () => actorPubkey,
|
|
getSessionActor: () => ({ pubkey: actorPubkey, privateKey: sessionPrivateKey }),
|
|
ensureSessionActor: async () => actorPubkey,
|
|
resolveActiveSigner: () => null,
|
|
shouldRequestExtensionPermissions: () => false,
|
|
signEventWithPrivateKey: async (event, key) => ({
|
|
...event,
|
|
id: `${event.kind}:${event.created_at}:${event.content?.length || 0}`,
|
|
sig: `sig-${key}`,
|
|
}),
|
|
ensureNostrTools: async () => ({
|
|
nip04: {
|
|
encrypt: async (priv, pub, plaintext) => `enc:${priv}:${pub}:${plaintext}`,
|
|
},
|
|
}),
|
|
getWriteRelays: () => relayUrls,
|
|
getRelayFallback: () => relayUrls,
|
|
getPool: () => ({
|
|
publish(urls, event) {
|
|
publishCalls.push({ urls, event });
|
|
return {
|
|
on(eventName, handler) {
|
|
if (eventName === "ok") {
|
|
setTimeout(handler, 0);
|
|
}
|
|
return this;
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const items = [{ type: "e", value: "cached-pointer", watchedAt: 234 }];
|
|
const result = await manager.publishSnapshot(items, {
|
|
actorPubkey,
|
|
relays: relayUrls,
|
|
snapshotId: "snapshot-cache",
|
|
});
|
|
|
|
assert.equal(result.ok, true, "snapshot publish should succeed");
|
|
assert(publishCalls.length > 0, "publish helper should be invoked");
|
|
|
|
const actorKey = actorPubkey.toLowerCase();
|
|
const cacheEntry = manager.cache.get(actorKey);
|
|
assert.ok(cacheEntry, "cache entry should be stored for the actor");
|
|
assert.equal(cacheEntry.items.length, 1, "cache should retain canonical items");
|
|
assert.equal(cacheEntry.items[0]?.value, "cached-pointer");
|
|
assert.equal(cacheEntry.snapshotId, result.snapshotId);
|
|
assert.deepStrictEqual(cacheEntry.items, result.items);
|
|
|
|
const storage = manager.getStorage();
|
|
assert.ok(storage.actors[actorKey], "storage should persist the cache entry");
|
|
assert.equal(storage.actors[actorKey].snapshotId, result.snapshotId);
|
|
assert.equal(storage.actors[actorKey].items.length, 1);
|
|
|
|
const fingerprint = manager.fingerprints.get(actorKey);
|
|
assert.ok(fingerprint, "fingerprint cache should be populated");
|
|
assert.equal(fingerprint, cacheEntry.fingerprint);
|
|
} finally {
|
|
manager.clear();
|
|
}
|
|
});
|