mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
1663 lines
45 KiB
JavaScript
1663 lines
45 KiB
JavaScript
import "./test-helpers/setup-localstorage.mjs";
|
|
import assert from "node:assert/strict";
|
|
|
|
const {
|
|
WATCH_HISTORY_LIST_IDENTIFIER,
|
|
WATCH_HISTORY_KIND,
|
|
WATCH_HISTORY_FETCH_EVENT_LIMIT,
|
|
} = await import("../js/config.js");
|
|
const {
|
|
NOTE_TYPES,
|
|
getNostrEventSchema,
|
|
setNostrEventSchemaOverrides,
|
|
buildWatchHistoryIndexEvent,
|
|
buildWatchHistoryChunkEvent,
|
|
} = await import("../js/nostrEventSchemas.js");
|
|
const { compareByWatchedAtDesc, getWatchedAtScore } = await import(
|
|
"../js/watchHistorySort.js"
|
|
);
|
|
|
|
if (typeof globalThis.window === "undefined") {
|
|
globalThis.window = {};
|
|
}
|
|
|
|
if (!globalThis.window.NostrTools) {
|
|
globalThis.window.NostrTools = {};
|
|
}
|
|
|
|
globalThis.window.NostrTools.getEventHash = (event) => {
|
|
const payload = `${event.kind}:${event.pubkey}:${event.created_at}`;
|
|
return `hash-${Buffer.from(payload).toString("hex")}`;
|
|
};
|
|
|
|
globalThis.window.NostrTools.signEvent = (event, privateKey) =>
|
|
`sig-${privateKey.slice(0, 8)}-${event.created_at}`;
|
|
|
|
delete globalThis.window.NostrTools.nip04;
|
|
delete globalThis.window.nostr;
|
|
|
|
const { nostrClient } = await import("../js/nostr.js");
|
|
|
|
function createPublishingClient(actorPubkey, options = {}) {
|
|
const client = new nostrClient.constructor();
|
|
const publishedEvents = [];
|
|
const payloads = [];
|
|
const scheduledRepublishes = [];
|
|
const cancelledRepublishes = [];
|
|
const persistedEntries = [];
|
|
const onPublish =
|
|
typeof options.onPublish === "function" ? options.onPublish : null;
|
|
|
|
client.pool = {
|
|
publish(urls, event) {
|
|
publishedEvents.push(event);
|
|
const publishDecision = onPublish ? onPublish(event) : null;
|
|
const shouldSucceed = publishDecision?.ok !== false;
|
|
return {
|
|
on(eventName, handler) {
|
|
setTimeout(() => {
|
|
if (shouldSucceed) {
|
|
if (eventName === "ok" || eventName === "seen") {
|
|
handler();
|
|
}
|
|
} else if (eventName === "failed") {
|
|
const providedError = publishDecision?.error;
|
|
const error =
|
|
providedError instanceof Error
|
|
? providedError
|
|
: new Error(
|
|
providedError
|
|
? String(providedError)
|
|
: "mock publish failure"
|
|
);
|
|
handler(error);
|
|
}
|
|
}, 0);
|
|
},
|
|
};
|
|
},
|
|
list: async () => [],
|
|
};
|
|
client.relays = ["wss://unit.test"];
|
|
client.pubkey = "";
|
|
client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
|
|
client.ensureSessionActor = async () => actorPubkey;
|
|
client.encryptWatchHistoryPayload = async (_actor, payload) => {
|
|
payloads.push(payload);
|
|
return { ok: true, ciphertext: JSON.stringify(payload) };
|
|
};
|
|
client.persistWatchHistoryEntry = (_actor, entry) => {
|
|
persistedEntries.push(entry);
|
|
};
|
|
client.cancelWatchHistoryRepublish = (actor) => {
|
|
cancelledRepublishes.push(actor);
|
|
};
|
|
client.scheduleWatchHistoryRepublish = (actor, items) => {
|
|
scheduledRepublishes.push({ actor, items });
|
|
};
|
|
return {
|
|
client,
|
|
publishedEvents,
|
|
payloads,
|
|
scheduledRepublishes,
|
|
cancelledRepublishes,
|
|
persistedEntries,
|
|
};
|
|
}
|
|
|
|
function createDecryptClient(actorPubkey) {
|
|
const client = new nostrClient.constructor();
|
|
client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" };
|
|
client.ensureSessionActor = async () => actorPubkey;
|
|
return client;
|
|
}
|
|
|
|
const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
const LEGACY_WATCH_HISTORY_KIND = 30078;
|
|
function deriveChunkPrefix(identifier) {
|
|
if (typeof identifier !== "string") {
|
|
return "watch-history:v2";
|
|
}
|
|
|
|
const trimmed = identifier.trim().replace(/\/+$/, "");
|
|
if (!trimmed) {
|
|
return "watch-history:v2";
|
|
}
|
|
|
|
if (/[:/]index$/i.test(trimmed)) {
|
|
const base = trimmed.replace(/[:/]index$/i, "").replace(/\/+$/, "");
|
|
return base || "watch-history:v2";
|
|
}
|
|
|
|
if (/:v\d+$/i.test(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
|
|
return `${trimmed}:v2`;
|
|
}
|
|
|
|
const WATCH_HISTORY_CHUNK_PREFIX = deriveChunkPrefix(
|
|
WATCH_HISTORY_LIST_IDENTIFIER
|
|
);
|
|
|
|
const indexSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX);
|
|
assert(indexSchema, "index schema should be available");
|
|
|
|
const chunkSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK);
|
|
assert(chunkSchema, "chunk schema should be available");
|
|
|
|
const sampleChunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/sample/0`;
|
|
const sampleChunkAddresses = [
|
|
`${WATCH_HISTORY_KIND}:${ACTOR}:${sampleChunkIdentifier}`,
|
|
`${WATCH_HISTORY_KIND}:${ACTOR}:${WATCH_HISTORY_CHUNK_PREFIX}/sample/1`,
|
|
];
|
|
|
|
const sampleIndexEvent = buildWatchHistoryIndexEvent({
|
|
pubkey: ACTOR,
|
|
created_at: 111,
|
|
snapshotId: "sample",
|
|
totalChunks: 2,
|
|
chunkAddresses: sampleChunkAddresses,
|
|
});
|
|
|
|
const sampleIndexIdentifierTag = sampleIndexEvent.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
|
);
|
|
assert(sampleIndexIdentifierTag, "index event should include identifier tag");
|
|
assert.equal(
|
|
sampleIndexIdentifierTag[1],
|
|
WATCH_HISTORY_LIST_IDENTIFIER,
|
|
"index event should use canonical watch history identifier"
|
|
);
|
|
|
|
const sampleChunksTag = sampleIndexEvent.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "chunks"
|
|
);
|
|
assert(sampleChunksTag, "index event should include chunks tag");
|
|
assert.equal(
|
|
Number.parseInt(sampleChunksTag[1], 10),
|
|
2,
|
|
"chunks tag should reflect total chunks"
|
|
);
|
|
|
|
const sampleIndexAddresses = sampleIndexEvent.tags
|
|
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
|
|
.map((tag) => tag[1]);
|
|
assert.deepEqual(
|
|
sampleIndexAddresses,
|
|
sampleChunkAddresses,
|
|
"index event should list chunk addresses"
|
|
);
|
|
|
|
const sampleChunkEvent = buildWatchHistoryChunkEvent({
|
|
pubkey: ACTOR,
|
|
created_at: 222,
|
|
chunkIdentifier: sampleChunkIdentifier,
|
|
snapshotId: "sample",
|
|
chunkIndex: 0,
|
|
totalChunks: 2,
|
|
pointerTags: [["e", "sample-event"]],
|
|
content: "ciphertext",
|
|
});
|
|
|
|
const chunkIdentifierTag = sampleChunkEvent.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
|
);
|
|
assert(chunkIdentifierTag, "chunk event should include identifier tag");
|
|
assert.equal(
|
|
chunkIdentifierTag[1],
|
|
sampleChunkIdentifier,
|
|
"chunk event should use provided identifier"
|
|
);
|
|
assert(
|
|
sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "snapshot"),
|
|
"chunk event should include snapshot tag"
|
|
);
|
|
assert(
|
|
sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk"),
|
|
"chunk event should include chunk tag"
|
|
);
|
|
assert(
|
|
sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "encrypted"),
|
|
"chunk event should include encrypted tag"
|
|
);
|
|
|
|
setNostrEventSchemaOverrides({
|
|
[NOTE_TYPES.WATCH_HISTORY_INDEX]: {
|
|
identifierTag: { name: "d", value: "custom:index" },
|
|
},
|
|
[NOTE_TYPES.WATCH_HISTORY_CHUNK]: {
|
|
identifierTag: { name: "d", value: "custom:chunk" },
|
|
},
|
|
});
|
|
|
|
const overriddenIndexEvent = buildWatchHistoryIndexEvent({
|
|
pubkey: ACTOR,
|
|
created_at: 333,
|
|
snapshotId: "override",
|
|
totalChunks: 1,
|
|
});
|
|
assert(
|
|
overriddenIndexEvent.tags.some(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:index"
|
|
),
|
|
"index builder should honor overrides"
|
|
);
|
|
|
|
const overriddenChunkEvent = buildWatchHistoryChunkEvent({
|
|
pubkey: ACTOR,
|
|
created_at: 444,
|
|
snapshotId: "override",
|
|
chunkIndex: 0,
|
|
totalChunks: 1,
|
|
pointerTags: [],
|
|
content: "cipher",
|
|
});
|
|
assert(
|
|
overriddenChunkEvent.tags.some(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:chunk"
|
|
),
|
|
"chunk builder should honor identifier override"
|
|
);
|
|
|
|
setNostrEventSchemaOverrides({});
|
|
|
|
const { client: publishingClient, publishedEvents, payloads } =
|
|
createPublishingClient(ACTOR);
|
|
|
|
publishingClient.watchHistoryPayloadMaxBytes = 400;
|
|
publishingClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT;
|
|
|
|
const baseWatchedAt = 1_700_000_000_000;
|
|
const longPointers = Array.from({ length: 8 }, (_, index) => {
|
|
const relay = index % 3 === 0 ? "wss://relay.unit" : null;
|
|
const watchedAt = baseWatchedAt + index * 60_000;
|
|
if (index % 2 === 0) {
|
|
return {
|
|
type: "e",
|
|
value: `event-${index}-${"x".repeat(90)}`,
|
|
relay,
|
|
watchedAt,
|
|
};
|
|
}
|
|
return {
|
|
type: "a",
|
|
value: `${WATCH_HISTORY_KIND}:${ACTOR}:history-${index}-${"y".repeat(60)}`,
|
|
relay,
|
|
watchedAt,
|
|
};
|
|
});
|
|
|
|
const publishResult = await publishingClient.publishWatchHistorySnapshot(
|
|
ACTOR,
|
|
longPointers
|
|
);
|
|
|
|
assert.equal(publishResult.ok, true, "publish should report success");
|
|
assert(publishResult.events.length > 1, "snapshot should include index and chunk events");
|
|
assert.equal(
|
|
publishResult.events.length,
|
|
payloads.length + 1,
|
|
"index event should accompany each chunk payload"
|
|
);
|
|
|
|
payloads.forEach((payload) => {
|
|
const size = JSON.stringify(payload).length;
|
|
assert(
|
|
size <= publishingClient.watchHistoryPayloadMaxBytes,
|
|
`payload size ${size} should respect cap`
|
|
);
|
|
assert.equal(payload.version, 2, "chunk payload should use version 2");
|
|
});
|
|
|
|
const snapshotIds = new Set(
|
|
publishResult.events.map((event) =>
|
|
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1]
|
|
)
|
|
);
|
|
assert.equal(snapshotIds.size, 1, "all events should share a snapshot id");
|
|
|
|
const indexEvent = publishResult.indexEvent;
|
|
assert(indexEvent, "publish result should include index event");
|
|
|
|
const indexIdentifierTag = indexEvent.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
|
);
|
|
assert(indexIdentifierTag, "index event should include identifier tag");
|
|
assert.equal(
|
|
indexIdentifierTag[1],
|
|
WATCH_HISTORY_LIST_IDENTIFIER,
|
|
"index event should use canonical identifier"
|
|
);
|
|
|
|
const indexChunksTag = indexEvent.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "chunks"
|
|
);
|
|
assert(indexChunksTag, "index event should include chunks tag");
|
|
assert.equal(
|
|
Number.parseInt(indexChunksTag[1], 10),
|
|
publishResult.chunks.length,
|
|
"index event should report chunk count"
|
|
);
|
|
|
|
const chunkEvents = publishResult.chunks.map((chunk) => chunk.event);
|
|
const chunkIdentifiers = chunkEvents.map((event) =>
|
|
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]
|
|
);
|
|
|
|
assert.equal(
|
|
chunkIdentifiers.length,
|
|
chunkEvents.length,
|
|
"each chunk should expose a d tag"
|
|
);
|
|
|
|
const uniqueChunkIdentifiers = new Set(chunkIdentifiers);
|
|
assert.equal(
|
|
uniqueChunkIdentifiers.size,
|
|
chunkEvents.length,
|
|
"each chunk should have a unique d tag"
|
|
);
|
|
|
|
chunkIdentifiers.forEach((identifier, index) => {
|
|
assert(identifier, `chunk ${index} should include identifier`);
|
|
assert(
|
|
identifier.startsWith(WATCH_HISTORY_CHUNK_PREFIX),
|
|
`chunk ${index} should use the chunk prefix`
|
|
);
|
|
assert.notEqual(
|
|
identifier,
|
|
WATCH_HISTORY_LIST_IDENTIFIER,
|
|
"chunk identifiers should differ from index identifier"
|
|
);
|
|
});
|
|
|
|
const indexAddresses = indexEvent.tags
|
|
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
|
|
.map((tag) => tag[1]);
|
|
|
|
const expectedChunkAddresses = chunkEvents.map((event) => {
|
|
const identifier = event.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
|
)?.[1];
|
|
assert(identifier, "chunk should include identifier tag");
|
|
return `${WATCH_HISTORY_KIND}:${ACTOR}:${identifier}`;
|
|
});
|
|
|
|
expectedChunkAddresses.forEach((address) => {
|
|
assert(
|
|
indexAddresses.includes(address),
|
|
`index event should reference ${address}`
|
|
);
|
|
});
|
|
|
|
const CHUNK_FAILURE_ACTOR = `${ACTOR}-chunk-failure`;
|
|
const {
|
|
client: chunkFailureClient,
|
|
publishedEvents: chunkFailureEvents,
|
|
payloads: chunkFailurePayloads,
|
|
scheduledRepublishes: chunkFailureRepublishes,
|
|
cancelledRepublishes: chunkFailureCancels,
|
|
} = createPublishingClient(CHUNK_FAILURE_ACTOR, {
|
|
onPublish(event) {
|
|
const chunkTag = event.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "chunk"
|
|
);
|
|
if (chunkTag) {
|
|
const chunkIndex = Number.parseInt(chunkTag[1], 10);
|
|
const total = Number.parseInt(chunkTag[2], 10);
|
|
if (
|
|
Number.isInteger(chunkIndex) &&
|
|
Number.isInteger(total) &&
|
|
(total > 1 ? chunkIndex === total - 1 : chunkIndex === 0)
|
|
) {
|
|
return { ok: false, error: new Error("chunk publish failed") };
|
|
}
|
|
}
|
|
return { ok: true };
|
|
},
|
|
});
|
|
|
|
chunkFailureClient.watchHistoryPayloadMaxBytes =
|
|
publishingClient.watchHistoryPayloadMaxBytes;
|
|
chunkFailureClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT;
|
|
|
|
const chunkFailureResult = await chunkFailureClient.publishWatchHistorySnapshot(
|
|
CHUNK_FAILURE_ACTOR,
|
|
longPointers
|
|
);
|
|
|
|
assert.equal(
|
|
chunkFailureResult.ok,
|
|
false,
|
|
"chunk failure should report overall failure"
|
|
);
|
|
assert.equal(
|
|
chunkFailureResult.indexEvent,
|
|
null,
|
|
"chunk failure should omit index event"
|
|
);
|
|
assert.equal(
|
|
chunkFailureResult.events.length,
|
|
chunkFailureResult.chunks.length,
|
|
"chunk failure should not include index event in combined events"
|
|
);
|
|
assert.equal(
|
|
chunkFailureResult.indexResults.length,
|
|
0,
|
|
"chunk failure should skip index publish attempts"
|
|
);
|
|
assert(
|
|
chunkFailureResult.chunks.some((chunk) => chunk.success === false),
|
|
"chunk failure results should flag the failed chunk"
|
|
);
|
|
assert.equal(
|
|
chunkFailureRepublishes.length,
|
|
1,
|
|
"chunk failure should schedule a retry"
|
|
);
|
|
assert.equal(
|
|
chunkFailureCancels.length,
|
|
0,
|
|
"chunk failure should not cancel retries"
|
|
);
|
|
assert.equal(
|
|
chunkFailureRepublishes[0].actor,
|
|
CHUNK_FAILURE_ACTOR,
|
|
"chunk failure retry should target the affected actor"
|
|
);
|
|
assert(
|
|
chunkFailureEvents.every((event) =>
|
|
event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk")
|
|
),
|
|
"chunk failure should avoid publishing index events"
|
|
);
|
|
assert.equal(
|
|
chunkFailurePayloads.length,
|
|
chunkFailureResult.chunks.length,
|
|
"chunk failure should still prepare payloads for each chunk"
|
|
);
|
|
|
|
const chunkFailureCache = chunkFailureClient.watchHistoryCache.get(
|
|
CHUNK_FAILURE_ACTOR
|
|
);
|
|
|
|
assert(chunkFailureCache, "chunk failure should cache a watch history entry");
|
|
assert.equal(
|
|
chunkFailureCache.chunkPointers.index,
|
|
null,
|
|
"chunk failure cache should not retain an index pointer"
|
|
);
|
|
assert(
|
|
Array.from(chunkFailureCache.chunkEvents.values()).every((event) =>
|
|
event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk")
|
|
),
|
|
"chunk failure cache should only persist chunk events"
|
|
);
|
|
assert.deepEqual(
|
|
chunkFailureRepublishes[0].items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay || null,
|
|
watchedAt: item.watchedAt,
|
|
})),
|
|
chunkFailureResult.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay || null,
|
|
watchedAt: item.watchedAt,
|
|
})),
|
|
"republish scheduler should receive the remaining items"
|
|
);
|
|
|
|
const LOGGED_ACTOR = `${ACTOR}-logged`;
|
|
const {
|
|
client: noSignerClient,
|
|
publishedEvents: noSignerPublishedEvents,
|
|
scheduledRepublishes: noSignerRepublishes,
|
|
persistedEntries: noSignerPersistedEntries,
|
|
} = createPublishingClient(LOGGED_ACTOR);
|
|
|
|
noSignerClient.pubkey = LOGGED_ACTOR;
|
|
|
|
const noSignerItems = [
|
|
{
|
|
type: "e",
|
|
value: `${LOGGED_ACTOR}-event`,
|
|
watchedAt: baseWatchedAt + 123,
|
|
},
|
|
{
|
|
type: "a",
|
|
value: `${WATCH_HISTORY_KIND}:${LOGGED_ACTOR}:history`,
|
|
watchedAt: baseWatchedAt + 456,
|
|
},
|
|
];
|
|
|
|
const noSignerResult = await noSignerClient.publishWatchHistorySnapshot(
|
|
LOGGED_ACTOR,
|
|
noSignerItems
|
|
);
|
|
|
|
assert.equal(
|
|
noSignerResult.ok,
|
|
false,
|
|
"logged actor without signer should report failure"
|
|
);
|
|
assert.equal(
|
|
noSignerResult.error,
|
|
"signing-unavailable",
|
|
"missing signer failure should flag availability"
|
|
);
|
|
assert.equal(
|
|
noSignerPublishedEvents.length,
|
|
0,
|
|
"no events should publish when signer unavailable"
|
|
);
|
|
assert.equal(
|
|
noSignerRepublishes.length,
|
|
1,
|
|
"missing signer should schedule a retry"
|
|
);
|
|
assert.equal(
|
|
noSignerRepublishes[0].actor,
|
|
LOGGED_ACTOR,
|
|
"retry should target the logged actor"
|
|
);
|
|
|
|
const cachedLoggedEntry = noSignerClient.watchHistoryCache.get(LOGGED_ACTOR);
|
|
assert(cachedLoggedEntry, "missing signer should keep snapshot cached");
|
|
assert.equal(
|
|
cachedLoggedEntry.items.length,
|
|
noSignerItems.length,
|
|
"cached entry should retain merged items"
|
|
);
|
|
assert.equal(
|
|
noSignerPersistedEntries.length,
|
|
1,
|
|
"missing signer should persist cached payload"
|
|
);
|
|
assert.deepEqual(
|
|
cachedLoggedEntry.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
})),
|
|
noSignerResult.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
})),
|
|
"returned items should mirror cached payload"
|
|
);
|
|
|
|
const scheduledLoggedKeys = new Set(
|
|
noSignerRepublishes[0].items.map((item) => `${item.type}:${item.value}`)
|
|
);
|
|
const cachedLoggedKeys = new Set(
|
|
cachedLoggedEntry.items.map((item) => `${item.type}:${item.value}`)
|
|
);
|
|
assert.deepEqual(
|
|
scheduledLoggedKeys,
|
|
cachedLoggedKeys,
|
|
"scheduled retry should include cached items"
|
|
);
|
|
|
|
const NO_SEEN_ACTOR = `${ACTOR}-no-seen`;
|
|
const {
|
|
client: noSeenClient,
|
|
publishedEvents: noSeenPublishedEvents,
|
|
} = createPublishingClient(NO_SEEN_ACTOR);
|
|
|
|
noSeenClient.pool.publish = (_urls, event) => {
|
|
noSeenPublishedEvents.push(event);
|
|
return {
|
|
on(eventName, handler) {
|
|
if (eventName === "seen") {
|
|
throw new TypeError("seen unsupported");
|
|
}
|
|
if (eventName === "ok") {
|
|
setTimeout(handler, 0);
|
|
} else if (eventName === "failed") {
|
|
// Never invoke failed handlers in this mock; they should remain idle.
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
const noSeenResult = await noSeenClient.publishWatchHistorySnapshot(
|
|
NO_SEEN_ACTOR,
|
|
[{ type: "e", value: "no-seen-pointer", relay: null }]
|
|
);
|
|
|
|
assert.equal(
|
|
noSeenResult.ok,
|
|
true,
|
|
"publish should succeed when relay handle rejects seen handlers"
|
|
);
|
|
assert(
|
|
noSeenResult.events.length > 0,
|
|
"publish should still return the emitted events when seen is unsupported"
|
|
);
|
|
|
|
delete globalThis.window.nostr;
|
|
|
|
if (!globalThis.window.NostrTools.nip04) {
|
|
globalThis.window.NostrTools.nip04 = {};
|
|
}
|
|
|
|
globalThis.window.NostrTools.nip04.decrypt = async (
|
|
_priv,
|
|
_pub,
|
|
ciphertext
|
|
) => ciphertext;
|
|
|
|
const decryptClient = createDecryptClient(ACTOR);
|
|
decryptClient.relays = ["wss://unit.test"];
|
|
decryptClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT;
|
|
decryptClient.pool = {
|
|
list: async () => publishedEvents,
|
|
};
|
|
|
|
const fetched = await decryptClient.fetchWatchHistory(ACTOR);
|
|
|
|
assert.equal(
|
|
fetched.items.length,
|
|
longPointers.length,
|
|
"fetch should reassemble all chunked items"
|
|
);
|
|
|
|
assert.deepEqual(
|
|
fetched.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay || null,
|
|
watchedAt: item.watchedAt,
|
|
})),
|
|
longPointers.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay || null,
|
|
watchedAt: item.watchedAt,
|
|
})),
|
|
"chunked fetch should preserve pointer order and timestamps"
|
|
);
|
|
|
|
if (typeof localStorage !== "undefined") {
|
|
localStorage.clear();
|
|
}
|
|
|
|
const headEvent = publishResult.event;
|
|
|
|
const legacyPointerIdentifiers = new Set();
|
|
const legacyIndexEvent = JSON.parse(JSON.stringify(headEvent));
|
|
legacyIndexEvent.kind = LEGACY_WATCH_HISTORY_KIND;
|
|
legacyIndexEvent.tags = legacyIndexEvent.tags.map((tag) => {
|
|
if (
|
|
Array.isArray(tag) &&
|
|
tag[0] === "a" &&
|
|
typeof tag[1] === "string" &&
|
|
!legacyPointerIdentifiers.size
|
|
) {
|
|
const [kindStr, pubkey, ...identifierParts] = tag[1].split(":");
|
|
if (kindStr === String(WATCH_HISTORY_KIND) && identifierParts.length) {
|
|
const identifier = identifierParts.join(":");
|
|
legacyPointerIdentifiers.add(identifier);
|
|
const legacyAddress = `${LEGACY_WATCH_HISTORY_KIND}:${pubkey}:${identifier}`;
|
|
const clone = tag.slice();
|
|
clone[1] = legacyAddress;
|
|
return clone;
|
|
}
|
|
}
|
|
return tag;
|
|
});
|
|
const legacyChunkIdentifier = legacyPointerIdentifiers.size
|
|
? Array.from(legacyPointerIdentifiers)[0]
|
|
: null;
|
|
|
|
const snapshotId = headEvent.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
|
|
)?.[1];
|
|
|
|
const freshClient = createDecryptClient(ACTOR);
|
|
freshClient.relays = ["wss://unit.test"];
|
|
freshClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT;
|
|
|
|
const observedFilters = [];
|
|
freshClient.pool = {
|
|
list: async (_relays, filters) => {
|
|
observedFilters.push(filters);
|
|
|
|
const firstFilter = filters[0] || {};
|
|
const dFilter = Array.isArray(firstFilter["#d"])
|
|
? firstFilter["#d"]
|
|
: [];
|
|
|
|
if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) {
|
|
return [legacyIndexEvent];
|
|
}
|
|
|
|
const requestedIdentifiers = new Set();
|
|
const requestedSnapshots = new Set();
|
|
|
|
for (const filter of filters) {
|
|
if (Array.isArray(filter?.["#d"])) {
|
|
for (const identifier of filter["#d"]) {
|
|
requestedIdentifiers.add(identifier);
|
|
}
|
|
}
|
|
if (Array.isArray(filter?.["#snapshot"])) {
|
|
for (const value of filter["#snapshot"]) {
|
|
requestedSnapshots.add(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
const responses = [];
|
|
|
|
for (const event of publishResult.events) {
|
|
if (event.id === headEvent.id) {
|
|
continue;
|
|
}
|
|
const cloned = JSON.parse(JSON.stringify(event));
|
|
if (legacyChunkIdentifier) {
|
|
const identifierTag = cloned.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
|
);
|
|
if (identifierTag?.[1] === legacyChunkIdentifier) {
|
|
cloned.kind = LEGACY_WATCH_HISTORY_KIND;
|
|
}
|
|
}
|
|
const identifier = event.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
|
)?.[1];
|
|
const eventSnapshot = event.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
|
|
)?.[1];
|
|
|
|
if (identifier && requestedIdentifiers.has(identifier)) {
|
|
responses.push(cloned);
|
|
continue;
|
|
}
|
|
if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) {
|
|
responses.push(cloned);
|
|
}
|
|
}
|
|
|
|
return responses;
|
|
},
|
|
};
|
|
|
|
const rebuilt = await freshClient.fetchWatchHistory(ACTOR);
|
|
|
|
assert.equal(
|
|
rebuilt.items.length,
|
|
longPointers.length,
|
|
"fresh client should rebuild full watch history list"
|
|
);
|
|
|
|
assert.deepEqual(
|
|
rebuilt.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay || null,
|
|
watchedAt: item.watchedAt,
|
|
})),
|
|
longPointers.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay || null,
|
|
watchedAt: item.watchedAt,
|
|
})),
|
|
"rebuilt snapshot should preserve timestamps"
|
|
);
|
|
|
|
assert(
|
|
observedFilters.some((filters) =>
|
|
filters.some((filter) => {
|
|
const kinds = Array.isArray(filter?.kinds) ? filter.kinds : [];
|
|
return (
|
|
kinds.includes(WATCH_HISTORY_KIND) &&
|
|
kinds.includes(LEGACY_WATCH_HISTORY_KIND)
|
|
);
|
|
})
|
|
),
|
|
"fetchWatchHistory should request both new and legacy kinds during migration"
|
|
);
|
|
|
|
assert(
|
|
observedFilters.length >= 2,
|
|
"fetch should include a follow-up query for chunk events"
|
|
);
|
|
|
|
const followUpFilters = observedFilters[observedFilters.length - 1];
|
|
const identifiersRequested = new Set();
|
|
let snapshotRequested = false;
|
|
|
|
for (const filter of followUpFilters) {
|
|
if (Array.isArray(filter?.["#d"])) {
|
|
for (const identifier of filter["#d"]) {
|
|
identifiersRequested.add(identifier);
|
|
}
|
|
}
|
|
if (Array.isArray(filter?.["#snapshot"])) {
|
|
snapshotRequested = snapshotRequested || filter["#snapshot"].includes(snapshotId);
|
|
}
|
|
}
|
|
|
|
assert(
|
|
chunkEvents.every((event) => {
|
|
const identifier = event.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "d"
|
|
)?.[1];
|
|
return (
|
|
identifiersRequested.has(identifier) ||
|
|
(snapshotId && snapshotRequested)
|
|
);
|
|
}),
|
|
"follow-up query should target chunk identifiers or snapshot"
|
|
);
|
|
|
|
const OVERFLOW_ACTOR = `${ACTOR}-overflow`;
|
|
const overflowSnapshotId = "overflow-snapshot";
|
|
const overflowTotalChunks = 18;
|
|
const overflowBaseCreatedAt = 1_700_555_000;
|
|
|
|
const overflowPointers = Array.from({ length: overflowTotalChunks }, (_, index) => {
|
|
const relay = index % 3 === 0 ? "wss://overflow.unit" : null;
|
|
return {
|
|
type: index % 2 === 0 ? "e" : "a",
|
|
value:
|
|
index % 2 === 0
|
|
? `overflow-event-${index}`
|
|
: `${WATCH_HISTORY_KIND}:${OVERFLOW_ACTOR}:overflow-${index}`,
|
|
relay,
|
|
watchedAt: baseWatchedAt + index * 45_000,
|
|
};
|
|
});
|
|
|
|
const overflowChunkEvents = overflowPointers.map((pointer, index) => {
|
|
const chunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/${overflowSnapshotId}/${index}`;
|
|
const pointerTag = [pointer.type, pointer.value];
|
|
if (pointer.relay) {
|
|
pointerTag.push(pointer.relay);
|
|
}
|
|
const payload = {
|
|
version: 2,
|
|
snapshot: overflowSnapshotId,
|
|
chunkIndex: index,
|
|
totalChunks: overflowTotalChunks,
|
|
items: [pointer],
|
|
};
|
|
const event = buildWatchHistoryChunkEvent({
|
|
pubkey: OVERFLOW_ACTOR,
|
|
created_at: overflowBaseCreatedAt + (overflowTotalChunks - index),
|
|
chunkIdentifier,
|
|
snapshotId: overflowSnapshotId,
|
|
chunkIndex: index,
|
|
totalChunks: overflowTotalChunks,
|
|
pointerTags: [pointerTag],
|
|
chunkAddresses: [],
|
|
content: JSON.stringify(payload),
|
|
});
|
|
event.id = window.NostrTools.getEventHash(event);
|
|
event.sig = `sig-overflow-chunk-${index}`;
|
|
return event;
|
|
});
|
|
|
|
const overflowIndexEvent = buildWatchHistoryIndexEvent({
|
|
pubkey: OVERFLOW_ACTOR,
|
|
created_at: overflowBaseCreatedAt + overflowTotalChunks + 5,
|
|
snapshotId: overflowSnapshotId,
|
|
totalChunks: overflowTotalChunks,
|
|
chunkAddresses: [],
|
|
});
|
|
overflowIndexEvent.id = window.NostrTools.getEventHash(overflowIndexEvent);
|
|
overflowIndexEvent.sig = "sig-overflow-index";
|
|
|
|
const overflowClient = createDecryptClient(OVERFLOW_ACTOR);
|
|
overflowClient.relays = ["wss://overflow.test"];
|
|
// Simulate deployments that kept the fetch limit near the historical default.
|
|
overflowClient.watchHistoryFetchEventLimit = 12;
|
|
|
|
const overflowChunkLimits = [];
|
|
overflowClient.pool = {
|
|
list: async (_relays, filters) => {
|
|
const firstFilter = filters[0] || {};
|
|
const dFilter = Array.isArray(firstFilter["#d"]) ? firstFilter["#d"] : [];
|
|
if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) {
|
|
return [overflowIndexEvent];
|
|
}
|
|
|
|
filters.forEach((filter) => {
|
|
if (Number.isFinite(filter?.limit)) {
|
|
overflowChunkLimits.push(filter.limit);
|
|
}
|
|
});
|
|
|
|
const requestedSnapshots = new Set();
|
|
filters.forEach((filter) => {
|
|
if (Array.isArray(filter?.["#snapshot"])) {
|
|
filter["#snapshot"].forEach((value) => {
|
|
if (typeof value === "string" && value) {
|
|
requestedSnapshots.add(value);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
if (!requestedSnapshots.size) {
|
|
return [];
|
|
}
|
|
|
|
return overflowChunkEvents.filter((event) => {
|
|
const snapshotTag = event.tags.find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
|
|
);
|
|
return snapshotTag && requestedSnapshots.has(snapshotTag[1]);
|
|
});
|
|
},
|
|
};
|
|
|
|
const overflowFetched = await overflowClient.fetchWatchHistory(OVERFLOW_ACTOR);
|
|
|
|
assert.equal(
|
|
overflowFetched.items.length,
|
|
overflowTotalChunks,
|
|
"fetch should hydrate snapshots larger than the legacy limit"
|
|
);
|
|
|
|
const maxOverflowLimit = overflowChunkLimits.length
|
|
? Math.max(...overflowChunkLimits)
|
|
: 0;
|
|
|
|
assert(
|
|
maxOverflowLimit >= overflowTotalChunks + 1,
|
|
"chunk fetch should cover all chunks plus the newest index event"
|
|
);
|
|
|
|
const MONOTONIC_ACTOR = `${ACTOR}-monotonic`;
|
|
const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } =
|
|
createPublishingClient(MONOTONIC_ACTOR);
|
|
|
|
const monotonicOriginalDateNow = Date.now;
|
|
try {
|
|
const monotonicBaseSeconds = 1_700_123_456;
|
|
Date.now = () => monotonicBaseSeconds * 1000;
|
|
|
|
const firstMonotonicPointers = [
|
|
{ type: "e", value: "event-monotonic-1", relay: null },
|
|
{ type: "e", value: "event-monotonic-2", relay: null },
|
|
];
|
|
|
|
const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
|
|
MONOTONIC_ACTOR,
|
|
firstMonotonicPointers
|
|
);
|
|
|
|
const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry(
|
|
firstSnapshot.event,
|
|
firstMonotonicPointers,
|
|
Date.now()
|
|
);
|
|
monotonicPublishingClient.watchHistoryCache.set(
|
|
MONOTONIC_ACTOR,
|
|
baselineEntry
|
|
);
|
|
|
|
const secondMonotonicPointers = [
|
|
{ type: "e", value: "event-monotonic-3", relay: null },
|
|
{ type: "e", value: "event-monotonic-1", relay: null },
|
|
];
|
|
|
|
const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot(
|
|
MONOTONIC_ACTOR,
|
|
secondMonotonicPointers,
|
|
baselineEntry
|
|
);
|
|
|
|
assert(
|
|
secondSnapshot.event.created_at > firstSnapshot.event.created_at,
|
|
"new snapshot should advance created_at when reusing the same second"
|
|
);
|
|
|
|
const monotonicDecryptClient = createDecryptClient(MONOTONIC_ACTOR);
|
|
monotonicDecryptClient.pool = { list: async () => monotonicEvents };
|
|
|
|
const monotonicFetched = await monotonicDecryptClient.fetchWatchHistory(
|
|
MONOTONIC_ACTOR
|
|
);
|
|
|
|
assert.deepEqual(
|
|
monotonicFetched.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay || null,
|
|
})),
|
|
[
|
|
{ type: "e", value: "event-monotonic-3", relay: null },
|
|
{ type: "e", value: "event-monotonic-1", relay: null },
|
|
],
|
|
"newer snapshot should win pointer ordering"
|
|
);
|
|
} finally {
|
|
Date.now = monotonicOriginalDateNow;
|
|
}
|
|
|
|
localStorage.clear();
|
|
|
|
const UPDATE_ACTOR = `${ACTOR}-update`;
|
|
const { client: updateClient } = createPublishingClient(UPDATE_ACTOR);
|
|
const updateOriginalDateNow = Date.now;
|
|
try {
|
|
const updateTimestamp = 1_701_111_111_111;
|
|
Date.now = () => updateTimestamp;
|
|
|
|
const updateResult = await updateClient.updateWatchHistoryList({
|
|
type: "e",
|
|
value: "update-pointer",
|
|
relay: null,
|
|
});
|
|
|
|
assert.equal(
|
|
updateResult.ok,
|
|
true,
|
|
"updateWatchHistoryList should publish when adding a new pointer"
|
|
);
|
|
const updateEntry = updateClient.watchHistoryCache.get(UPDATE_ACTOR);
|
|
assert(updateEntry, "updateWatchHistoryList should cache the new entry");
|
|
assert.equal(
|
|
updateEntry.items[0].watchedAt,
|
|
updateTimestamp,
|
|
"cached entry should record watchedAt from Date.now()"
|
|
);
|
|
assert.equal(
|
|
updateResult.items[0].watchedAt,
|
|
updateTimestamp,
|
|
"returned publish result should include the watchedAt timestamp"
|
|
);
|
|
} finally {
|
|
Date.now = updateOriginalDateNow;
|
|
}
|
|
|
|
const REMOVE_ACTOR = `${ACTOR}-remove`;
|
|
const {
|
|
client: removalClient,
|
|
payloads: removalPayloads,
|
|
} = createPublishingClient(REMOVE_ACTOR);
|
|
|
|
const removalPointers = [
|
|
{
|
|
type: "e",
|
|
value: "remove-first",
|
|
relay: null,
|
|
watchedAt: 1_701_222_000_000,
|
|
},
|
|
{
|
|
type: "e",
|
|
value: "remove-second",
|
|
relay: null,
|
|
watchedAt: 1_701_222_100_000,
|
|
},
|
|
];
|
|
|
|
await removalClient.publishWatchHistorySnapshot(
|
|
REMOVE_ACTOR,
|
|
removalPointers
|
|
);
|
|
|
|
const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`;
|
|
const removalResult = await removalClient.removeWatchHistoryItem(removalKey);
|
|
|
|
assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed");
|
|
assert.equal(
|
|
removalResult.removedKey,
|
|
removalKey,
|
|
"removeWatchHistoryItem should echo the removed pointer key"
|
|
);
|
|
|
|
const latestRemovalPayload = removalPayloads[removalPayloads.length - 1];
|
|
assert.equal(
|
|
latestRemovalPayload.items.length,
|
|
1,
|
|
"removal republish should trim the pointer list"
|
|
);
|
|
assert.equal(
|
|
latestRemovalPayload.items[0].value,
|
|
removalPointers[0].value,
|
|
"republished payload should only include remaining pointer"
|
|
);
|
|
assert.equal(
|
|
latestRemovalPayload.items[0].watchedAt,
|
|
removalPointers[0].watchedAt,
|
|
"republished payload should preserve watchedAt"
|
|
);
|
|
|
|
const removalEntry = removalClient.watchHistoryCache.get(REMOVE_ACTOR);
|
|
assert.equal(
|
|
removalEntry.items.length,
|
|
1,
|
|
"watch history cache should drop the removed pointer"
|
|
);
|
|
assert.equal(
|
|
removalEntry.items[0].value,
|
|
removalPointers[0].value,
|
|
"remaining cached pointer should match the survivor"
|
|
);
|
|
assert.equal(
|
|
removalEntry.items[0].watchedAt,
|
|
removalPointers[0].watchedAt,
|
|
"remaining cached pointer should retain watchedAt"
|
|
);
|
|
|
|
assert.equal(
|
|
removalResult.items.map((item) => item.value).join(","),
|
|
removalPointers.slice(0, 1).map((item) => item.value).join(","),
|
|
"removeWatchHistoryItem result should reflect updated items"
|
|
);
|
|
assert.equal(
|
|
removalResult.items[0].watchedAt,
|
|
removalPointers[0].watchedAt,
|
|
"removeWatchHistoryItem result should preserve watchedAt"
|
|
);
|
|
|
|
const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`;
|
|
const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier";
|
|
const videoPointer = {
|
|
type: "a",
|
|
value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`,
|
|
relay: null,
|
|
};
|
|
|
|
const pointerClient = createDecryptClient(ACTOR);
|
|
const pointerEntry = pointerClient.createWatchHistoryEntry(
|
|
null,
|
|
[videoPointer],
|
|
Date.now()
|
|
);
|
|
pointerClient.watchHistoryCache.set(ACTOR, pointerEntry);
|
|
pointerClient.fetchWatchHistory = async () => {};
|
|
|
|
const observedResolveFilters = [];
|
|
const videoEvent = {
|
|
id: "video-pointer-event",
|
|
kind: WATCH_HISTORY_KIND,
|
|
pubkey: VIDEO_POINTER_AUTHOR,
|
|
created_at: 1_700_555_000,
|
|
tags: [["d", VIDEO_POINTER_IDENTIFIER]],
|
|
content: JSON.stringify({
|
|
version: 3,
|
|
title: "Pointer Video",
|
|
url: "https://cdn.example.com/video.mp4",
|
|
}),
|
|
};
|
|
|
|
pointerClient.pool = {
|
|
list: async (_relays, filters) => {
|
|
observedResolveFilters.push(filters);
|
|
return [videoEvent];
|
|
},
|
|
};
|
|
|
|
const resolvedFromPointer = await pointerClient.resolveWatchHistory(1);
|
|
|
|
const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`;
|
|
|
|
assert.equal(
|
|
resolvedFromPointer.length,
|
|
1,
|
|
"resolveWatchHistory should return results for 'a' style video pointers"
|
|
);
|
|
assert.equal(
|
|
resolvedFromPointer[0].id,
|
|
videoEvent.id,
|
|
"resolved video should match the pointer event id"
|
|
);
|
|
assert.equal(
|
|
resolvedFromPointer[0].title,
|
|
"Pointer Video",
|
|
"resolved video should parse title from the event payload"
|
|
);
|
|
|
|
assert(resolvedFromPointer[0].watchHistory, "resolved video should include watchHistory metadata");
|
|
assert.equal(
|
|
resolvedFromPointer[0].watchHistory.key,
|
|
pointerKey,
|
|
"watchHistory metadata should expose the pointer key"
|
|
);
|
|
assert.equal(
|
|
resolvedFromPointer[0].watchHistory.pointer.value,
|
|
videoPointer.value,
|
|
"watchHistory metadata should include the pointer payload"
|
|
);
|
|
assert.equal(
|
|
resolvedFromPointer[0].watchHistory.pointer.watchedAt,
|
|
pointerEntry.items[0].watchedAt,
|
|
"watchHistory metadata should preserve watchedAt"
|
|
);
|
|
|
|
const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR);
|
|
assert(
|
|
cachedEntry.resolvedVideos.has(pointerKey),
|
|
"resolved cache should retain the video"
|
|
);
|
|
assert.equal(
|
|
cachedEntry.resolvedVideos.get(pointerKey)?.id,
|
|
videoEvent.id,
|
|
"resolved cache should store the converted video result"
|
|
);
|
|
assert(
|
|
cachedEntry.delivered.has(pointerKey),
|
|
"delivery set should mark the pointer as delivered"
|
|
);
|
|
|
|
assert(
|
|
observedResolveFilters.some((filters) =>
|
|
filters.some((filter) =>
|
|
Array.isArray(filter?.["#d"]) &&
|
|
filter["#d"].includes(VIDEO_POINTER_IDENTIFIER)
|
|
)
|
|
),
|
|
"resolveWatchHistory should request the video identifier instead of treating it as a chunk"
|
|
);
|
|
|
|
const secondResolve = await pointerClient.resolveWatchHistory(1);
|
|
assert.equal(
|
|
secondResolve.length,
|
|
0,
|
|
"subsequent resolve calls should avoid refetching already delivered videos"
|
|
);
|
|
|
|
pointerClient.resetWatchHistoryProgress(ACTOR);
|
|
assert.equal(
|
|
cachedEntry.delivered.size,
|
|
0,
|
|
"resetWatchHistoryProgress should clear delivered pointers"
|
|
);
|
|
|
|
pointerClient.pool = {
|
|
list: async () => {
|
|
throw new Error("cached resolve should not trigger network fetch");
|
|
},
|
|
};
|
|
|
|
const cachedResolve = await pointerClient.resolveWatchHistory(1);
|
|
assert.equal(
|
|
cachedResolve.length,
|
|
1,
|
|
"reset should allow cached videos to be delivered again"
|
|
);
|
|
assert.equal(
|
|
cachedResolve[0].id,
|
|
videoEvent.id,
|
|
"cached resolve should reuse the stored video payload"
|
|
);
|
|
assert(
|
|
cachedResolve[0].watchHistory?.watchedAt === pointerEntry.items[0].watchedAt,
|
|
"cached resolve should retain watchHistory metadata"
|
|
);
|
|
|
|
const originalDateNow = Date.now;
|
|
try {
|
|
const baseTime = 1_700_000_000_000;
|
|
const ttlActor = `${ACTOR}-ttl`;
|
|
const pointer = { type: "e", value: "event-ttl", relay: null };
|
|
|
|
const cachingClient = createDecryptClient(ttlActor);
|
|
cachingClient.watchHistoryCacheTtlMs = 24 * 60 * 60 * 1000;
|
|
|
|
Date.now = () => baseTime;
|
|
|
|
const entry = cachingClient.createWatchHistoryEntry(null, [pointer], baseTime);
|
|
cachingClient.watchHistoryCache.set(ttlActor, entry);
|
|
cachingClient.persistWatchHistoryEntry(ttlActor, entry);
|
|
|
|
const reloadClient = createDecryptClient(ttlActor);
|
|
reloadClient.watchHistoryCacheTtlMs = cachingClient.watchHistoryCacheTtlMs;
|
|
|
|
Date.now = () => baseTime + 60 * 60 * 1000;
|
|
|
|
const cachedSnapshot = await reloadClient.fetchWatchHistory(ttlActor);
|
|
assert.equal(
|
|
cachedSnapshot.items.length,
|
|
1,
|
|
"cached snapshot should survive within extended TTL"
|
|
);
|
|
assert.equal(
|
|
cachedSnapshot.items[0].value,
|
|
pointer.value,
|
|
"cached snapshot should preserve pointer"
|
|
);
|
|
|
|
localStorage.clear();
|
|
|
|
const expiryClient = createDecryptClient(ttlActor);
|
|
expiryClient.watchHistoryCacheTtlMs = 1_000;
|
|
|
|
Date.now = () => baseTime;
|
|
|
|
const expiringEntry = expiryClient.createWatchHistoryEntry(
|
|
null,
|
|
[pointer],
|
|
baseTime
|
|
);
|
|
expiryClient.watchHistoryCache.set(ttlActor, expiringEntry);
|
|
expiryClient.persistWatchHistoryEntry(ttlActor, expiringEntry);
|
|
|
|
Date.now = () => baseTime + 1_500;
|
|
|
|
const expiredSnapshot = await expiryClient.fetchWatchHistory(ttlActor);
|
|
assert.equal(
|
|
expiredSnapshot.items.length,
|
|
0,
|
|
"expired snapshot should be dropped"
|
|
);
|
|
const cachedFallback = expiryClient.watchHistoryCache.get(ttlActor);
|
|
assert.equal(
|
|
cachedFallback?.items?.length ?? 0,
|
|
0,
|
|
"expired snapshot should not preserve stale items in memory"
|
|
);
|
|
const storage = expiryClient.getWatchHistoryStorage();
|
|
assert.equal(
|
|
Object.prototype.hasOwnProperty.call(storage.actors, ttlActor),
|
|
false,
|
|
"expired snapshot should be removed from localStorage"
|
|
);
|
|
} finally {
|
|
Date.now = originalDateNow;
|
|
}
|
|
|
|
const plaintextPayload = {
|
|
version: 2,
|
|
snapshot: "plaintext-snapshot",
|
|
chunkIndex: 0,
|
|
totalChunks: 1,
|
|
items: [
|
|
{
|
|
type: "e",
|
|
value: "plaintext-pointer",
|
|
relay: "wss://relay.unit",
|
|
watchedAt: 1_700_000_123_000,
|
|
},
|
|
],
|
|
};
|
|
|
|
const plaintextChunkEvent = {
|
|
kind: WATCH_HISTORY_KIND,
|
|
pubkey: ACTOR,
|
|
created_at: 333,
|
|
tags: [
|
|
["d", `${WATCH_HISTORY_CHUNK_PREFIX}/plaintext/0`],
|
|
["snapshot", plaintextPayload.snapshot],
|
|
["chunk", String(plaintextPayload.chunkIndex), String(plaintextPayload.totalChunks)],
|
|
["e", plaintextPayload.items[0].value, plaintextPayload.items[0].relay],
|
|
],
|
|
content: JSON.stringify(plaintextPayload),
|
|
};
|
|
|
|
const originalWindowNostr = globalThis.window.nostr;
|
|
if (!globalThis.window.NostrTools) {
|
|
globalThis.window.NostrTools = {};
|
|
}
|
|
if (!globalThis.window.NostrTools.nip04) {
|
|
globalThis.window.NostrTools.nip04 = {};
|
|
}
|
|
const originalSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt;
|
|
|
|
try {
|
|
globalThis.window.nostr = {
|
|
nip04: {
|
|
decrypt: () => {
|
|
throw new Error("plaintext chunk should not trigger extension decrypt");
|
|
},
|
|
},
|
|
};
|
|
globalThis.window.NostrTools.nip04.decrypt = () => {
|
|
throw new Error("plaintext chunk should not trigger session decrypt");
|
|
};
|
|
|
|
const plaintextClient = createDecryptClient(ACTOR);
|
|
const plaintextResult = await plaintextClient.decryptWatchHistoryEvent(
|
|
plaintextChunkEvent,
|
|
ACTOR
|
|
);
|
|
|
|
assert.deepEqual(
|
|
plaintextResult.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay,
|
|
watchedAt: item.watchedAt,
|
|
})),
|
|
plaintextPayload.items,
|
|
"plaintext payload should be parsed without invoking nip04 helpers",
|
|
);
|
|
assert.equal(
|
|
plaintextResult.snapshot,
|
|
plaintextPayload.snapshot,
|
|
"plaintext payload should preserve snapshot metadata",
|
|
);
|
|
assert.equal(
|
|
plaintextResult.chunkIndex,
|
|
plaintextPayload.chunkIndex,
|
|
"plaintext payload should preserve chunk index",
|
|
);
|
|
assert.equal(
|
|
plaintextResult.totalChunks,
|
|
plaintextPayload.totalChunks,
|
|
"plaintext payload should preserve chunk count",
|
|
);
|
|
} finally {
|
|
globalThis.window.nostr = originalWindowNostr;
|
|
if (originalSessionDecrypt === undefined) {
|
|
delete globalThis.window.NostrTools.nip04.decrypt;
|
|
} else {
|
|
globalThis.window.NostrTools.nip04.decrypt = originalSessionDecrypt;
|
|
}
|
|
}
|
|
|
|
const encryptedPayload = {
|
|
version: 2,
|
|
snapshot: "encrypted-snapshot",
|
|
chunkIndex: 0,
|
|
totalChunks: 1,
|
|
items: [
|
|
{
|
|
type: "e",
|
|
value: "encrypted-pointer",
|
|
relay: "wss://relay.unit",
|
|
watchedAt: 1_700_000_456_000,
|
|
},
|
|
],
|
|
};
|
|
|
|
const encryptedCiphertext = "ZW5jcnlwdGVkQ29udGVudD9mb29iYXI=?iv=YWJjMTIz";
|
|
|
|
const encryptedChunkEvent = {
|
|
kind: WATCH_HISTORY_KIND,
|
|
pubkey: ACTOR,
|
|
created_at: 444,
|
|
tags: [
|
|
["d", `${WATCH_HISTORY_CHUNK_PREFIX}/encrypted/0`],
|
|
["encrypted", "nip04"],
|
|
["snapshot", encryptedPayload.snapshot],
|
|
["chunk", String(encryptedPayload.chunkIndex), String(encryptedPayload.totalChunks)],
|
|
["e", encryptedPayload.items[0].value, encryptedPayload.items[0].relay],
|
|
],
|
|
content: encryptedCiphertext,
|
|
};
|
|
|
|
const encryptedClient = createDecryptClient(ACTOR);
|
|
encryptedClient.pubkey = ACTOR;
|
|
|
|
const priorWindowNostr = globalThis.window.nostr;
|
|
const priorSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt;
|
|
let extensionDecryptCalls = 0;
|
|
let sessionDecryptCalls = 0;
|
|
|
|
try {
|
|
globalThis.window.nostr = {
|
|
nip04: {
|
|
decrypt: async (pubkey, ciphertext) => {
|
|
extensionDecryptCalls += 1;
|
|
assert.equal(pubkey, ACTOR, "extension decrypt should target the actor");
|
|
assert.equal(
|
|
ciphertext,
|
|
encryptedCiphertext,
|
|
"extension decrypt should receive the chunk ciphertext",
|
|
);
|
|
return JSON.stringify(encryptedPayload);
|
|
},
|
|
},
|
|
};
|
|
|
|
globalThis.window.NostrTools.nip04.decrypt = async () => {
|
|
sessionDecryptCalls += 1;
|
|
return JSON.stringify(encryptedPayload);
|
|
};
|
|
|
|
const decryptedResult = await encryptedClient.decryptWatchHistoryEvent(
|
|
encryptedChunkEvent,
|
|
ACTOR
|
|
);
|
|
|
|
assert.equal(
|
|
extensionDecryptCalls,
|
|
1,
|
|
"encrypted chunks should invoke extension nip04 decrypt",
|
|
);
|
|
assert.equal(
|
|
sessionDecryptCalls,
|
|
0,
|
|
"successful extension decrypt should skip session fallback",
|
|
);
|
|
assert.deepEqual(
|
|
decryptedResult.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay,
|
|
watchedAt: item.watchedAt,
|
|
})),
|
|
encryptedPayload.items,
|
|
"encrypted payload should be reconstructed from decrypted content",
|
|
);
|
|
assert.equal(
|
|
decryptedResult.snapshot,
|
|
encryptedPayload.snapshot,
|
|
"encrypted payload should preserve snapshot metadata",
|
|
);
|
|
} finally {
|
|
globalThis.window.nostr = priorWindowNostr;
|
|
if (priorSessionDecrypt === undefined) {
|
|
delete globalThis.window.NostrTools.nip04.decrypt;
|
|
} else {
|
|
globalThis.window.NostrTools.nip04.decrypt = priorSessionDecrypt;
|
|
}
|
|
}
|
|
|
|
const missingSignerClient = createDecryptClient(LOGGED_ACTOR);
|
|
missingSignerClient.pubkey = LOGGED_ACTOR;
|
|
missingSignerClient.sessionActor = null;
|
|
|
|
let missingSignerEnsureCalls = 0;
|
|
missingSignerClient.ensureSessionActor = async () => {
|
|
missingSignerEnsureCalls += 1;
|
|
return LOGGED_ACTOR;
|
|
};
|
|
|
|
const priorMissingSignerNostr = globalThis.window.nostr;
|
|
const priorMissingSignerSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt;
|
|
let missingSignerSessionDecryptCalls = 0;
|
|
|
|
try {
|
|
delete globalThis.window.nostr;
|
|
globalThis.window.NostrTools.nip04.decrypt = async () => {
|
|
missingSignerSessionDecryptCalls += 1;
|
|
return JSON.stringify(encryptedPayload);
|
|
};
|
|
|
|
const missingSignerChunkEvent = {
|
|
...encryptedChunkEvent,
|
|
pubkey: LOGGED_ACTOR,
|
|
id: "missing-signer-event",
|
|
};
|
|
|
|
const missingSignerResult = await missingSignerClient.decryptWatchHistoryEvent(
|
|
missingSignerChunkEvent,
|
|
LOGGED_ACTOR
|
|
);
|
|
|
|
assert.equal(
|
|
missingSignerEnsureCalls,
|
|
0,
|
|
"missing signer should not trigger session actor ensure"
|
|
);
|
|
assert.equal(
|
|
missingSignerSessionDecryptCalls,
|
|
0,
|
|
"missing signer should skip session decrypt fallback"
|
|
);
|
|
assert.equal(
|
|
missingSignerResult.needsSignerRetry,
|
|
true,
|
|
"missing signer fallback should mark retry flag"
|
|
);
|
|
assert.deepEqual(
|
|
missingSignerResult.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay || null,
|
|
})),
|
|
encryptedPayload.items.map((item) => ({
|
|
type: item.type,
|
|
value: item.value,
|
|
relay: item.relay || null,
|
|
})),
|
|
"missing signer fallback should preserve pointer metadata"
|
|
);
|
|
} finally {
|
|
if (priorMissingSignerNostr === undefined) {
|
|
delete globalThis.window.nostr;
|
|
} else {
|
|
globalThis.window.nostr = priorMissingSignerNostr;
|
|
}
|
|
if (priorMissingSignerSessionDecrypt === undefined) {
|
|
delete globalThis.window.NostrTools.nip04.decrypt;
|
|
} else {
|
|
globalThis.window.NostrTools.nip04.decrypt = priorMissingSignerSessionDecrypt;
|
|
}
|
|
}
|
|
|
|
const unsortedVideos = [
|
|
{ id: "alpha", watchHistory: { watchedAt: 1_700_000_000_500 } },
|
|
{ id: "bravo", watchHistory: { watchedAt: 1_700_000_010_000 } },
|
|
{ id: "charlie", created_at: 1_699_999_000 },
|
|
];
|
|
|
|
const sortedVideos = unsortedVideos.slice().sort(compareByWatchedAtDesc);
|
|
|
|
assert.deepEqual(
|
|
sortedVideos.map((video) => video.id),
|
|
["bravo", "alpha", "charlie"],
|
|
"videos should be ordered by watchedAt descending with created_at fallback",
|
|
);
|
|
|
|
const tieVideos = [
|
|
{ id: "echo", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_100 },
|
|
{ id: "delta", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 },
|
|
{ id: "foxtrot", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 },
|
|
];
|
|
|
|
tieVideos.sort(compareByWatchedAtDesc);
|
|
|
|
assert.equal(
|
|
getWatchedAtScore(tieVideos[0]),
|
|
getWatchedAtScore(tieVideos[1]),
|
|
"ties should preserve identical watchedAt scores",
|
|
);
|
|
|
|
assert.deepEqual(
|
|
tieVideos.map((video) => video.id),
|
|
["delta", "foxtrot", "echo"],
|
|
"created_at should break watchedAt ties with stable fallback",
|
|
);
|
|
|
|
console.log("watch history tests passed");
|