mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
Service-layer plumbing for bounded watch-history loading (slice B part 2). manager.resolve() now forwards chunkDecryptLimit to fetch() and records the resulting deferred-chunk count in a per-actor side-channel, so resolve() keeps returning a bare items array (its sole caller depends on that) while the "more older chunks available" signal is still retrievable. Exposed via getWatchHistoryDeferredChunkCount() on the manager and the NostrClient facade. clear() resets the side-channel. Still inert by default (no caller passes a finite limit yet); this is the substrate for activating fast-first-render. Tested: resolve under chunkDecryptLimit:1 returns only the newest chunk's items and reports deferredChunkCount=1, reset to 0 after clear(). Co-Authored-By: Claude Opus 4.8 <[email protected]>
805 lines
24 KiB
JavaScript
805 lines
24 KiB
JavaScript
import "../test-helpers/setup-localstorage.mjs";
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
buildWatchHistoryPayload,
|
|
createWatchHistoryManager,
|
|
fetchWatchHistory,
|
|
getWatchHistoryCacheTtlMs,
|
|
getWatchHistoryDeferredChunkCount,
|
|
getWatchHistoryStorage,
|
|
resolveWatchHistory,
|
|
} from "../../js/nostr/watchHistory.js";
|
|
import { WATCH_HISTORY_KIND } from "../../js/config.js";
|
|
import { NOTE_TYPES } from "../../js/nostrEventSchemas.js";
|
|
import { profileCache } from "../../js/state/profileCache.js";
|
|
|
|
const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v5";
|
|
|
|
function ensureLocalStorageCleared() {
|
|
if (typeof localStorage?.clear === "function") {
|
|
localStorage.clear();
|
|
}
|
|
}
|
|
|
|
test.beforeEach(() => {
|
|
ensureLocalStorageCleared();
|
|
// Clear profile cache memory
|
|
if (profileCache) {
|
|
profileCache.activePubkey = null;
|
|
profileCache.memoryCache.clear();
|
|
}
|
|
});
|
|
|
|
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({
|
|
getActivePubkey: () => profileCache.getActiveProfile(),
|
|
});
|
|
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;
|
|
|
|
// Test Fresh Actor
|
|
profileCache.setActiveProfile(freshActor);
|
|
const freshPayload = {
|
|
actor: freshActor,
|
|
snapshotId: "snap-fresh",
|
|
fingerprint: "fingerprint-fresh",
|
|
savedAt: baseNow - (ttl - 1000),
|
|
items: [{ type: "e", value: "fresh-pointer", watchedAt: 123 }],
|
|
metadata: { status: "ok" },
|
|
};
|
|
const freshKey = profileCache.getStorageKey(freshActor, NOTE_TYPES.WATCH_HISTORY);
|
|
localStorage.setItem(freshKey, JSON.stringify(freshPayload));
|
|
|
|
const freshStorage = getWatchHistoryStorage(manager);
|
|
const freshEntry = freshStorage.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");
|
|
|
|
|
|
// Test Stale Actor
|
|
profileCache.setActiveProfile(staleActor);
|
|
const stalePayload = {
|
|
actor: staleActor,
|
|
snapshotId: "snap-stale",
|
|
fingerprint: "fingerprint-stale",
|
|
savedAt: baseNow - (ttl + 1000),
|
|
items: [{ type: "e", value: "stale-pointer", watchedAt: 456 }],
|
|
metadata: { status: "stale" },
|
|
};
|
|
const staleKey = profileCache.getStorageKey(staleActor, NOTE_TYPES.WATCH_HISTORY);
|
|
localStorage.setItem(staleKey, JSON.stringify(stalePayload));
|
|
|
|
const staleStorage = getWatchHistoryStorage(manager);
|
|
const staleEntry = staleStorage.actors[staleActor];
|
|
|
|
assert.strictEqual(
|
|
staleEntry,
|
|
undefined,
|
|
"stale actor entry should be removed (pruned)",
|
|
);
|
|
|
|
} finally {
|
|
Date.now = originalNow;
|
|
manager.clear();
|
|
profileCache.activePubkey = null;
|
|
}
|
|
});
|
|
|
|
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("fetchWatchHistory decrypts encrypted pointer events with nip44 signer support", async () => {
|
|
const actorHex = "5".repeat(64);
|
|
const ciphertext = "nip44-ciphertext";
|
|
const decryptCalls = [];
|
|
|
|
const pointerEvent = {
|
|
id: "pointer-nip44",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_200_000,
|
|
content: ciphertext,
|
|
tags: [
|
|
["encrypted", "nip44"],
|
|
["a", "30078:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:fallback"],
|
|
["d", "watch-history-month"],
|
|
],
|
|
};
|
|
|
|
const signer = {
|
|
nip44Decrypt: async (pubkey, payload) => {
|
|
decryptCalls.push({ pubkey, payload });
|
|
assert.equal(pubkey, actorHex, "nip44 decrypt should target the actor pubkey");
|
|
assert.equal(payload, ciphertext, "nip44 decrypt should receive encrypted content");
|
|
return JSON.stringify({
|
|
version: 2,
|
|
items: [{ type: "e", value: "decrypted-from-pointer", watchedAt: 33 }],
|
|
});
|
|
},
|
|
};
|
|
|
|
const pool = {
|
|
async list() {
|
|
return [pointerEvent];
|
|
},
|
|
};
|
|
|
|
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, "nip44 decrypt should be attempted once");
|
|
assert.deepStrictEqual(
|
|
result.items.map((item) => item.value),
|
|
["decrypted-from-pointer"],
|
|
"decrypted pointer event payload should be used",
|
|
);
|
|
assert.equal(
|
|
result.items[0]?.watchedAt,
|
|
33,
|
|
"watched timestamp from decrypted payload should be preserved",
|
|
);
|
|
} finally {
|
|
manager.clear();
|
|
}
|
|
});
|
|
|
|
test("fetchWatchHistory routes decryption by ciphertext format and never tries the non-matching family (ignores a misleading encrypted tag)", async () => {
|
|
// A NIP-44 payload is plain base64 with NO "?iv=" marker. NIP-04 ciphertext
|
|
// always carries "?iv=". Here the event is *mislabeled* with an
|
|
// ["encrypted","nip04"] tag, but the ciphertext shape is authoritative.
|
|
// Under a NIP-46 remote signer, every decrypt attempt is a published relay
|
|
// RPC, so attempting the impossible nip04 family would be a wasted RPC that
|
|
// burns the serial queue / risks the rate limit. The decryptor must route by
|
|
// format and attempt ONLY nip44 — never nip04.
|
|
const actorHex = "6".repeat(64);
|
|
const ciphertext = "bmlwNDQtbWlzbGFiZWxlZC1wYXlsb2Fk"; // base64, no "?iv="
|
|
let nip04Calls = 0;
|
|
let nip44Calls = 0;
|
|
|
|
const pointerEvent = {
|
|
id: "pointer-mislabeled",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_300_000,
|
|
content: ciphertext,
|
|
tags: [
|
|
["encrypted", "nip04"], // deliberately misleading
|
|
["a", "30078:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:fallback"],
|
|
["d", "watch-history-month"],
|
|
],
|
|
};
|
|
|
|
const signer = {
|
|
// If routing were broken, the misleading tag would put nip04 first and this
|
|
// decoy would "succeed" with the wrong data — the assertions below catch it.
|
|
nip04Decrypt: async () => {
|
|
nip04Calls += 1;
|
|
return JSON.stringify({
|
|
version: 2,
|
|
items: [{ type: "e", value: "WRONG-nip04-decoy", watchedAt: 1 }],
|
|
});
|
|
},
|
|
nip44Decrypt: async (pubkey, payload) => {
|
|
nip44Calls += 1;
|
|
assert.equal(pubkey, actorHex, "nip44 decrypt should target the actor pubkey");
|
|
assert.equal(payload, ciphertext, "nip44 decrypt should receive the ciphertext");
|
|
return JSON.stringify({
|
|
version: 2,
|
|
items: [{ type: "e", value: "correct-nip44-decrypt", watchedAt: 77 }],
|
|
});
|
|
},
|
|
};
|
|
|
|
const pool = {
|
|
async list() {
|
|
return [pointerEvent];
|
|
},
|
|
};
|
|
|
|
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(
|
|
nip04Calls,
|
|
0,
|
|
"nip04 must NEVER be attempted for a nip44-shaped ciphertext (wasted NIP-46 RPC)",
|
|
);
|
|
assert.equal(nip44Calls, 1, "nip44 decrypt should be attempted exactly once");
|
|
assert.deepStrictEqual(
|
|
result.items.map((item) => item.value),
|
|
["correct-nip44-decrypt"],
|
|
"the nip44 plaintext must be used, not the nip04 decoy",
|
|
);
|
|
} finally {
|
|
manager.clear();
|
|
}
|
|
});
|
|
|
|
test("fetchWatchHistory caps uncached chunk decryption to chunkDecryptLimit, newest chunk first", async () => {
|
|
// A deep watch history references many monthly chunks. Under a NIP-46 remote
|
|
// signer each uncached chunk decrypt is a serialized published RPC, so we cap
|
|
// how many we decrypt up front and spend that budget on the *newest* chunks.
|
|
// The older chunks are deferred (reported via deferredChunkCount) for an
|
|
// on-demand "load older" pass.
|
|
const actorHex = "7".repeat(64);
|
|
const newCipher = "bmV3LWNodW5rLWNpcGhlcnRleHQ"; // base64, no "?iv=" => nip44
|
|
const oldCipher = "b2xkLWNodW5rLWNpcGhlcnRleHQ";
|
|
|
|
const pointerEvent = {
|
|
id: "pointer-cap",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_500_000,
|
|
content: JSON.stringify({
|
|
version: 2,
|
|
snapshot: "snap-cap",
|
|
items: [],
|
|
chunkIndex: 0,
|
|
totalChunks: 2,
|
|
}),
|
|
tags: [
|
|
["snapshot", "snap-cap"],
|
|
["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-new`],
|
|
["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-old`],
|
|
],
|
|
};
|
|
|
|
const newChunk = {
|
|
id: "chunk-new-id",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_500_200, // newer
|
|
content: newCipher,
|
|
tags: [["d", "chunk-new"], ["encrypted", "nip44"]],
|
|
};
|
|
const oldChunk = {
|
|
id: "chunk-old-id",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_400_000, // older
|
|
content: oldCipher,
|
|
tags: [["d", "chunk-old"], ["encrypted", "nip44"]],
|
|
};
|
|
|
|
const decryptedPayloads = [];
|
|
const signer = {
|
|
nip44Decrypt: async (pubkey, payload) => {
|
|
decryptedPayloads.push(payload);
|
|
if (payload === newCipher) {
|
|
return JSON.stringify({
|
|
version: 2,
|
|
items: [{ type: "e", value: "from-new-chunk", watchedAt: 200 }],
|
|
});
|
|
}
|
|
return JSON.stringify({
|
|
version: 2,
|
|
items: [{ type: "e", value: "from-old-chunk", watchedAt: 100 }],
|
|
});
|
|
},
|
|
};
|
|
|
|
let listCall = 0;
|
|
const pool = {
|
|
async list() {
|
|
listCall += 1;
|
|
if (listCall === 1) {
|
|
return [pointerEvent];
|
|
}
|
|
return [oldChunk, newChunk]; // relay returns out of recency order
|
|
},
|
|
};
|
|
|
|
const manager = createWatchHistoryManager({
|
|
getActivePubkey: () => actorHex,
|
|
resolveActiveSigner: () => signer,
|
|
shouldRequestExtensionPermissions: () => false,
|
|
getPool: () => pool,
|
|
getReadRelays: () => ["wss://relay.example"],
|
|
});
|
|
|
|
try {
|
|
const result = await fetchWatchHistory(manager, actorHex, {
|
|
forceRefresh: true,
|
|
chunkDecryptLimit: 1,
|
|
});
|
|
assert.equal(
|
|
decryptedPayloads.length,
|
|
1,
|
|
"only one chunk should be decrypted under chunkDecryptLimit: 1",
|
|
);
|
|
assert.equal(
|
|
decryptedPayloads[0],
|
|
newCipher,
|
|
"the newest chunk (highest created_at) must be the one decrypted",
|
|
);
|
|
assert.equal(
|
|
result.deferredChunkCount,
|
|
1,
|
|
"the older chunk should be reported as deferred",
|
|
);
|
|
assert.deepStrictEqual(
|
|
result.items.map((item) => item.value),
|
|
["from-new-chunk"],
|
|
"only the newest chunk's items should be present under the cap",
|
|
);
|
|
} finally {
|
|
manager.clear();
|
|
}
|
|
});
|
|
|
|
test("resolveWatchHistory threads chunkDecryptLimit and records the deferred chunk count", async () => {
|
|
// resolve() returns a bare items array (its sole caller depends on that), so
|
|
// the "more older chunks available" signal is exposed via a per-actor
|
|
// side-channel the service reads to decide whether to offer "load older".
|
|
const actorHex = "8".repeat(64);
|
|
const newCipher = "cmVzb2x2ZS1uZXctY2lwaGVy"; // base64, no "?iv=" => nip44
|
|
const oldCipher = "cmVzb2x2ZS1vbGQtY2lwaGVy";
|
|
|
|
const pointerEvent = {
|
|
id: "pointer-resolve-cap",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_600_000,
|
|
content: JSON.stringify({
|
|
version: 2,
|
|
snapshot: "snap-resolve",
|
|
items: [],
|
|
chunkIndex: 0,
|
|
totalChunks: 2,
|
|
}),
|
|
tags: [
|
|
["snapshot", "snap-resolve"],
|
|
["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-r-new`],
|
|
["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-r-old`],
|
|
],
|
|
};
|
|
const newChunk = {
|
|
id: "chunk-r-new-id",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_600_200,
|
|
content: newCipher,
|
|
tags: [["d", "chunk-r-new"], ["encrypted", "nip44"]],
|
|
};
|
|
const oldChunk = {
|
|
id: "chunk-r-old-id",
|
|
pubkey: actorHex,
|
|
created_at: 1_700_500_000,
|
|
content: oldCipher,
|
|
tags: [["d", "chunk-r-old"], ["encrypted", "nip44"]],
|
|
};
|
|
|
|
const signer = {
|
|
nip44Decrypt: async (_pubkey, payload) =>
|
|
JSON.stringify({
|
|
version: 2,
|
|
items: [
|
|
{
|
|
type: "e",
|
|
value: payload === newCipher ? "r-new" : "r-old",
|
|
watchedAt: payload === newCipher ? 200 : 100,
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
|
|
let listCall = 0;
|
|
const pool = {
|
|
async list() {
|
|
listCall += 1;
|
|
if (listCall === 1) {
|
|
return [pointerEvent];
|
|
}
|
|
return [oldChunk, newChunk];
|
|
},
|
|
};
|
|
|
|
const manager = createWatchHistoryManager({
|
|
getActivePubkey: () => actorHex,
|
|
resolveActiveSigner: () => signer,
|
|
shouldRequestExtensionPermissions: () => false,
|
|
getPool: () => pool,
|
|
getReadRelays: () => ["wss://relay.example"],
|
|
});
|
|
|
|
try {
|
|
assert.equal(
|
|
getWatchHistoryDeferredChunkCount(manager, actorHex),
|
|
0,
|
|
"no deferred chunks before any fetch",
|
|
);
|
|
const items = await resolveWatchHistory(manager, actorHex, {
|
|
forceRefresh: true,
|
|
chunkDecryptLimit: 1,
|
|
});
|
|
assert.ok(Array.isArray(items), "resolve must still return a bare items array");
|
|
assert.deepStrictEqual(
|
|
items.map((item) => item.value),
|
|
["r-new"],
|
|
"only the newest chunk's items should resolve under the cap",
|
|
);
|
|
assert.equal(
|
|
getWatchHistoryDeferredChunkCount(manager, actorHex),
|
|
1,
|
|
"the deferred side-channel should report the older chunk",
|
|
);
|
|
} finally {
|
|
manager.clear();
|
|
assert.equal(
|
|
getWatchHistoryDeferredChunkCount(manager, actorHex),
|
|
0,
|
|
"clear() must reset the deferred chunk side-channel",
|
|
);
|
|
}
|
|
});
|
|
|
|
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;
|
|
},
|
|
}),
|
|
async list() { return []; },
|
|
}),
|
|
});
|
|
|
|
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);
|
|
profileCache.setActiveProfile(actorPubkey);
|
|
|
|
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;
|
|
},
|
|
};
|
|
},
|
|
async list() { return []; },
|
|
}),
|
|
});
|
|
|
|
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();
|
|
profileCache.activePubkey = null;
|
|
}
|
|
});
|