mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
246 lines
6.6 KiB
JavaScript
246 lines
6.6 KiB
JavaScript
// Run with: node tests/comment-thread-service.test.mjs
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
import CommentThreadService from "../js/services/commentThreadService.js";
|
|
|
|
function createBaseVideo() {
|
|
return {
|
|
id: "video123",
|
|
pubkey: "authorpk",
|
|
kind: 30078,
|
|
tags: [["d", "video-root"], ["alt", "ignored"]],
|
|
};
|
|
}
|
|
|
|
function createComment({ id, pubkey, createdAt, parentId = null }) {
|
|
const baseTags = [
|
|
["e", "video123"],
|
|
["a", "30078:authorpk:video-root"],
|
|
];
|
|
const tags = parentId
|
|
? [...baseTags, ["e", parentId]]
|
|
: [...baseTags];
|
|
return {
|
|
id,
|
|
kind: 1,
|
|
pubkey,
|
|
created_at: createdAt,
|
|
content: `${id} content`,
|
|
tags,
|
|
};
|
|
}
|
|
|
|
const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
async function testThreadFlow() {
|
|
const video = createBaseVideo();
|
|
const initialEvents = [
|
|
createComment({ id: "comment-1", pubkey: "pk1", createdAt: 100 }),
|
|
createComment({ id: "comment-2", pubkey: "pk2", createdAt: 90 }),
|
|
createComment({
|
|
id: "reply-1",
|
|
pubkey: "pk3",
|
|
createdAt: 110,
|
|
parentId: "comment-1",
|
|
}),
|
|
];
|
|
|
|
const profileCache = new Map();
|
|
const batchCalls = [];
|
|
const app = {
|
|
getProfileCacheEntry: (pubkey) =>
|
|
profileCache.has(pubkey) ? { profile: profileCache.get(pubkey) } : null,
|
|
batchFetchProfiles: async (pubkeys) => {
|
|
batchCalls.push([...pubkeys]);
|
|
pubkeys.forEach((pubkey) => {
|
|
profileCache.set(pubkey, {
|
|
name: `Profile ${pubkey}`,
|
|
picture: `https://example.com/${pubkey}.png`,
|
|
});
|
|
});
|
|
},
|
|
};
|
|
|
|
let subscriptionHandler = null;
|
|
const nostrClient = {
|
|
fetchVideoComments: async () => initialEvents,
|
|
subscribeVideoComments: (_target, options = {}) => {
|
|
subscriptionHandler = typeof options.onEvent === "function"
|
|
? options.onEvent
|
|
: null;
|
|
return () => {
|
|
subscriptionHandler = null;
|
|
};
|
|
},
|
|
};
|
|
|
|
const received = {
|
|
initial: [],
|
|
appended: [],
|
|
errors: [],
|
|
};
|
|
|
|
const service = new CommentThreadService({
|
|
fetchVideoComments: (...args) => nostrClient.fetchVideoComments(...args),
|
|
subscribeVideoComments: (...args) =>
|
|
nostrClient.subscribeVideoComments(...args),
|
|
getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey),
|
|
batchFetchProfiles: (pubkeys) => app.batchFetchProfiles(pubkeys),
|
|
hydrationDebounceMs: 0,
|
|
});
|
|
|
|
service.setCallbacks({
|
|
onThreadReady: (payload) => received.initial.push(payload),
|
|
onCommentsAppended: (payload) => received.appended.push(payload),
|
|
onError: (error) => received.errors.push(error),
|
|
});
|
|
|
|
await service.loadThread({ video });
|
|
await tick(10);
|
|
await service.waitForProfileHydration();
|
|
|
|
assert.equal(received.initial.length, 1, "initial callback should fire once");
|
|
assert.equal(received.errors.length, 0, "no errors should be reported");
|
|
assert.equal(batchCalls.length, 1, "profiles should be fetched once for unknown authors");
|
|
assert.deepEqual(
|
|
new Set(batchCalls[0]),
|
|
new Set(["pk1", "pk2", "pk3"]),
|
|
"hydration should request each unique pubkey",
|
|
);
|
|
|
|
const initialPayload = received.initial[0];
|
|
assert.ok(initialPayload.childrenByParent instanceof Map);
|
|
assert.deepEqual(
|
|
initialPayload.topLevelIds,
|
|
["comment-2", "comment-1"],
|
|
"top-level comments should be sorted by created_at",
|
|
);
|
|
assert.deepEqual(
|
|
initialPayload.childrenByParent.get(null),
|
|
["comment-2", "comment-1"],
|
|
"root mapping should expose top-level ids",
|
|
);
|
|
assert.deepEqual(
|
|
initialPayload.childrenByParent.get("comment-1"),
|
|
["reply-1"],
|
|
"parent mapping should include replies",
|
|
);
|
|
assert.ok(
|
|
service.getProfile("pk3"),
|
|
"profiles should be cached for retrieval",
|
|
);
|
|
assert.ok(subscriptionHandler, "subscription handler should be registered");
|
|
|
|
const newReply = createComment({
|
|
id: "reply-2",
|
|
pubkey: "pk4",
|
|
createdAt: 120,
|
|
parentId: "comment-2",
|
|
});
|
|
|
|
subscriptionHandler(newReply);
|
|
await tick(10);
|
|
await service.waitForProfileHydration();
|
|
for (let attempt = 0; attempt < 5 && batchCalls.length < 2; attempt += 1) {
|
|
await tick(20);
|
|
}
|
|
assert.equal(
|
|
batchCalls.length,
|
|
2,
|
|
"hydration should fetch new reply authors",
|
|
);
|
|
assert.equal(
|
|
service.profileQueue.size,
|
|
0,
|
|
"profile queue should be cleared after hydration",
|
|
);
|
|
|
|
assert.equal(
|
|
received.appended.length,
|
|
1,
|
|
"one append callback should fire for the new reply",
|
|
);
|
|
const appendPayload = received.appended[0];
|
|
assert.equal(
|
|
appendPayload.parentCommentId,
|
|
"comment-2",
|
|
"append payload should identify the parent comment",
|
|
);
|
|
assert.deepEqual(
|
|
appendPayload.commentIds,
|
|
["reply-2"],
|
|
"append payload should surface the new reply id",
|
|
);
|
|
assert.ok(
|
|
service.getProfilesSnapshot().has("pk4"),
|
|
"new reply author should be hydrated and cached",
|
|
);
|
|
|
|
// Duplicate events should be ignored.
|
|
subscriptionHandler(newReply);
|
|
await tick();
|
|
assert.equal(
|
|
received.appended.length,
|
|
1,
|
|
"duplicate events should not trigger additional append callbacks",
|
|
);
|
|
|
|
service.teardown();
|
|
}
|
|
|
|
async function testTeardownCancelsHydration() {
|
|
const video = createBaseVideo();
|
|
const delayedReply = createComment({
|
|
id: "delayed-1",
|
|
pubkey: "pk9",
|
|
createdAt: 200,
|
|
});
|
|
|
|
let unsubscribed = false;
|
|
let batchCallCount = 0;
|
|
let subscriptionHandler = null;
|
|
const app = {
|
|
getProfileCacheEntry: () => null,
|
|
batchFetchProfiles: async () => {
|
|
batchCallCount += 1;
|
|
},
|
|
};
|
|
|
|
const nostrClient = {
|
|
fetchVideoComments: async () => [],
|
|
subscribeVideoComments: (_target, options = {}) => {
|
|
subscriptionHandler = typeof options.onEvent === "function"
|
|
? options.onEvent
|
|
: null;
|
|
return () => {
|
|
unsubscribed = true;
|
|
};
|
|
},
|
|
};
|
|
|
|
const service = new CommentThreadService({
|
|
fetchVideoComments: (...args) => nostrClient.fetchVideoComments(...args),
|
|
subscribeVideoComments: (...args) =>
|
|
nostrClient.subscribeVideoComments(...args),
|
|
getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey),
|
|
batchFetchProfiles: (pubkeys) => app.batchFetchProfiles(pubkeys),
|
|
hydrationDebounceMs: 50,
|
|
});
|
|
|
|
await service.loadThread({ video });
|
|
assert.ok(subscriptionHandler, "subscription handler should be available");
|
|
|
|
subscriptionHandler(delayedReply);
|
|
service.teardown();
|
|
await tick(80);
|
|
|
|
assert.equal(batchCallCount, 0, "teardown should cancel pending hydration timers");
|
|
assert.equal(unsubscribed, true, "teardown should unsubscribe from nostr feed");
|
|
}
|
|
|
|
await testThreadFlow();
|
|
await testTeardownCancelsHydration();
|
|
|
|
console.log("comment-thread-service tests passed");
|