From 3e27f06a135eaa5dee20958ecd416392c8f81a3a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 29 Dec 2025 22:00:32 -0500 Subject: [PATCH] Add comment thread and modal regression tests --- tests/comment-thread-service.test.mjs | 78 +++++++++++++++++++ tests/video-modal-comments.test.mjs | 107 ++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/tests/comment-thread-service.test.mjs b/tests/comment-thread-service.test.mjs index c538d5b4..d2481111 100644 --- a/tests/comment-thread-service.test.mjs +++ b/tests/comment-thread-service.test.mjs @@ -187,6 +187,39 @@ test("CommentThreadService surfaces cache write failures", () => { ); }); +test("CommentThreadService persists caches safely during teardown failures", async () => { + const warnings = []; + const service = new CommentThreadService({ + fetchVideoComments: async () => [ + createComment({ id: "persist-me", pubkey: "pk-cache", createdAt: 1 }), + ], + subscribeVideoComments: () => () => {}, + logger: { + warn: (...args) => warnings.push(args), + dev: { warn: (...args) => warnings.push(args) }, + user: { warn: (...args) => warnings.push(args) }, + }, + }); + + await service.loadThread({ video: createBaseVideo() }); + + globalThis.localStorage.setItem = () => { + throw new Error("storage blocked"); + }; + + assert.doesNotThrow(() => service.teardown()); + assert.equal( + warnings.some((args) => + args.some?.((value) => + typeof value === "string" && + value.includes("Failed to write comment cache for video123"), + ), + ), + true, + "teardown should warn when persistence fails but still reset state", + ); +}); + test("CommentThreadService logs cache usage and fallback decisions", async () => { const initialFlag = FEATURE_IMPROVED_COMMENT_FETCHING; const devLogs = []; @@ -416,6 +449,51 @@ test("CommentThreadService normalizes mixed-case pubkeys during hydration", asyn ); }); +test("CommentThreadService deduplicates mixed-case event ids and pubkeys", async () => { + const hydrationRequests = []; + const service = new CommentThreadService({ + fetchVideoComments: async () => [], + subscribeVideoComments: () => () => {}, + batchFetchProfiles: async (pubkeys) => { + hydrationRequests.push([...pubkeys]); + }, + hydrationDebounceMs: 0, + }); + + await service.loadThread({ video: createBaseVideo() }); + + const mixedCaseEvent = createComment({ + id: "CoMmEnT-123", // intentionally mixed case + pubkey: "PuBkEy-123", + createdAt: 10, + }); + const lowerCaseEvent = createComment({ + id: "comment-123", + pubkey: "pubkey-123", + createdAt: 20, + }); + + service.processIncomingEvent(mixedCaseEvent); + service.processIncomingEvent(lowerCaseEvent); + await service.flushProfileQueue(); + + assert.deepEqual( + service.getCommentIdsForParent(null), + ["comment-123"], + "mixed-case duplicates should collapse to a single normalized id", + ); + assert.equal( + service.getCommentEvent("COMMENT-123")?.created_at, + 20, + "newer updates should replace existing mixed-case entries", + ); + assert.deepEqual( + hydrationRequests, + [["pubkey-123"]], + "hydration should only request the normalized pubkey once", + ); +}); + test("CommentThreadService retries profile hydration before succeeding", async () => { const profiles = new Map(); const hydrationCalls = []; diff --git a/tests/video-modal-comments.test.mjs b/tests/video-modal-comments.test.mjs index 4521ed44..bf93a660 100644 --- a/tests/video-modal-comments.test.mjs +++ b/tests/video-modal-comments.test.mjs @@ -699,6 +699,113 @@ test( }, ); +test( + "VideoModalCommentController disposes safely during in-flight comment loads", + async () => { + const renderCalls = []; + const appendCalls = []; + const teardownCalls = []; + const setCallbacksCalls = []; + let onThreadReady = null; + let onCommentsAppended = null; + let resolveLoadThread; + + const commentThreadService = { + defaultLimit: 5, + setCallbacks: ({ onThreadReady: ready, onCommentsAppended: appended }) => { + onThreadReady = ready; + onCommentsAppended = appended; + }, + loadThread: () => + new Promise((resolve) => { + resolveLoadThread = resolve; + }), + teardown: () => { + teardownCalls.push("teardown"); + }, + processIncomingEvent: () => {}, + }; + + const videoModal = { + setCommentSectionCallbacks: (callbacks) => { + setCallbacksCalls.push(callbacks); + }, + setCommentsVisibility: () => {}, + hideCommentsDisabledMessage: () => {}, + showCommentsDisabledMessage: () => {}, + clearComments: () => {}, + resetCommentComposer: () => {}, + setCommentStatus: () => {}, + setCommentComposerState: () => {}, + renderComments: (payload) => renderCalls.push(payload), + appendComment: (payload) => appendCalls.push(payload), + }; + + const controller = new VideoModalCommentController({ + commentThreadService, + videoModal, + auth: { isLoggedIn: () => true }, + callbacks: { + showError: () => {}, + showStatus: () => {}, + muteAuthor: () => Promise.resolve(), + shouldHideAuthor: () => false, + }, + services: { + publishComment: async () => ({ ok: true, event: { id: "event", tags: [] } }), + }, + }); + + controller.load({ + id: "video-inflight", + pubkey: "AUTHORPK", + enableComments: true, + kind: 30078, + tags: [], + }); + + await Promise.resolve(); + teardownCalls.length = 0; // ignore the initial dispose inside load + + controller.dispose(); + + assert.equal(teardownCalls.length, 1, "dispose should teardown the active thread"); + assert.equal( + setCallbacksCalls.length > 0, + true, + "comment callbacks should be registered even before load resolves", + ); + + resolveLoadThread?.(); + await Promise.resolve(); + + onThreadReady?.({ + videoEventId: "video-inflight", + parentCommentId: null, + commentsById: new Map([["event-1", { id: "event-1", pubkey: "pk1" }]]), + childrenByParent: new Map([[null, ["event-1"]]]), + profiles: new Map(), + }); + + assert.equal(renderCalls.length, 0, "stale thread payloads should be ignored after dispose"); + + onCommentsAppended?.({ + videoEventId: "video-inflight", + parentCommentId: null, + commentIds: ["event-1"], + commentsById: new Map([["event-1", { id: "event-1", pubkey: "pk1" }]]), + childrenByParent: new Map([[null, ["event-1"]]]), + profiles: new Map(), + }); + + assert.equal( + appendCalls.length, + 0, + "stale appends should not render duplicate comments after dispose", + ); + }, +); + test( "VideoModalCommentController prompts login when publish requires authentication", async () => {