diff --git a/js/app.js b/js/app.js index 99e6dbd5..28739e23 100644 --- a/js/app.js +++ b/js/app.js @@ -112,6 +112,7 @@ import { } from "./ui/components/staticModalAccessibility.js"; import LoginModalController from "./ui/loginModalController.js"; import TagPreferenceMenuController from "./ui/tagPreferenceMenuController.js"; +import ReactionController from "./ui/reactionController.js"; import { pointerArrayToKey } from "./utils/pointer.js"; import resolveVideoPointer, { buildVideoAddressPointer, @@ -254,11 +255,39 @@ class Application { onRemovePoster: (reason) => this.forceRemoveModalPoster(reason), }); + this.reactionController = new ReactionController({ + services: { reactionCounter }, + ui: { + getVideoModal: () => this.videoModal, + showError: (msg) => this.showError(msg), + }, + state: { + getCurrentVideo: () => this.currentVideo, + getCurrentVideoPointer: () => this.currentVideoPointer, + getCurrentVideoPointerKey: () => this.currentVideoPointerKey, + }, + callbacks: { + isUserLoggedIn: () => this.isUserLoggedIn(), + normalizeHexPubkey: (val) => this.normalizeHexPubkey(val), + getPubkey: () => this.pubkey, + }, + }); + this.unsubscribeFromPubkeyState = subscribeToAppStateKey( "pubkey", (next, previous) => { if (next !== previous) { this.renderSavedProfiles(); + if ( + this.reactionController && + this.currentVideoPointer && + this.currentVideoPointerKey + ) { + this.reactionController.subscribe( + this.currentVideoPointer, + this.currentVideoPointerKey + ); + } } } ); @@ -361,13 +390,6 @@ class Application { } } - hideModal() { - if (this.videoModal) { - this.videoModal.close(); - } - // Also clear the active modal state if tracked elsewhere, though videoModal.close() usually handles it. - } - loadSavedProfilesFromStorage() { const result = this.authService.loadSavedProfilesFromStorage(); this.renderSavedProfiles(); @@ -1344,122 +1366,6 @@ class Application { } } - resetModalReactionState() { - this.modalReactionState = { - counts: { "+": 0, "-": 0 }, - total: 0, - userReaction: "", - }; - if (this.videoModal?.updateReactionSummary) { - this.videoModal.updateReactionSummary({ - total: 0, - counts: { "+": 0, "-": 0 }, - userReaction: "", - }); - } - } - - teardownModalReactionSubscription() { - if (typeof this.modalReactionUnsub === "function") { - try { - this.modalReactionUnsub(); - } catch (error) { - devLogger.warn( - "[reaction] Failed to tear down modal subscription:", - error, - ); - } - } - this.modalReactionUnsub = null; - this.modalReactionPointerKey = null; - this.resetModalReactionState(); - } - - subscribeModalReactions(pointer, pointerKey) { - if (!this.videoModal?.updateReactionSummary) { - this.teardownModalReactionSubscription(); - return; - } - - this.teardownModalReactionSubscription(); - - if (!pointer || !pointerKey) { - return; - } - - try { - const normalizedUser = this.normalizeHexPubkey(this.pubkey); - const unsubscribe = reactionCounter.subscribe(pointer, (snapshot) => { - const counts = { ...this.modalReactionState.counts }; - if (snapshot?.counts && typeof snapshot.counts === "object") { - for (const [key, value] of Object.entries(snapshot.counts)) { - counts[key] = this.normalizeReactionCount(value); - } - } - if (!Object.prototype.hasOwnProperty.call(counts, "+")) { - counts["+"] = 0; - } - if (!Object.prototype.hasOwnProperty.call(counts, "-")) { - counts["-"] = 0; - } - - let total = Number.isFinite(snapshot?.total) - ? Math.max(0, Number(snapshot.total)) - : 0; - if (!Number.isFinite(total) || total === 0) { - total = 0; - for (const value of Object.values(counts)) { - total += this.normalizeReactionCount(value); - } - } - - let userReaction = ""; - if (normalizedUser && snapshot?.reactions) { - const record = snapshot.reactions[normalizedUser] || null; - if (record && typeof record.content === "string") { - userReaction = - record.content === "+" - ? "+" - : record.content === "-" - ? "-" - : ""; - } - } - - this.modalReactionState = { - counts, - total, - userReaction, - }; - this.videoModal.updateReactionSummary({ - total, - counts, - userReaction, - }); - }); - - this.modalReactionPointerKey = pointerKey; - this.modalReactionUnsub = () => { - try { - unsubscribe?.(); - } catch (error) { - devLogger.warn( - "[reaction] Failed to tear down modal subscription:", - error, - ); - } finally { - this.modalReactionUnsub = null; - this.modalReactionPointerKey = null; - } - }; - } catch (error) { - devLogger.warn( - "[reaction] Failed to subscribe modal reaction counter:", - error, - ); - this.resetModalReactionState(); - } - } initializeCommentController() { if (!this.commentThreadService || !this.videoModal) { @@ -1591,206 +1497,9 @@ class Application { } } - applyModalReactionOptimisticUpdate(nextReaction) { - if (nextReaction !== "+" && nextReaction !== "-") { - return null; - } - - const previousCounts = { - ...(this.modalReactionState?.counts || {}), - }; - const previousTotalValue = Number.isFinite(this.modalReactionState?.total) - ? Math.max(0, Number(this.modalReactionState.total)) - : null; - const previousReaction = this.modalReactionState?.userReaction || ""; - - const likeBefore = this.normalizeReactionCount(previousCounts["+"]); - const dislikeBefore = this.normalizeReactionCount(previousCounts["-"]); - const otherCounts = {}; - for (const [key, value] of Object.entries(previousCounts)) { - if (key === "+" || key === "-") { - continue; - } - otherCounts[key] = this.normalizeReactionCount(value); - } - - let likeCount = likeBefore; - let dislikeCount = dislikeBefore; - - if (previousReaction === "+") { - likeCount = Math.max(0, likeCount - 1); - } else if (previousReaction === "-") { - dislikeCount = Math.max(0, dislikeCount - 1); - } - - if (nextReaction === "+") { - likeCount += 1; - } else if (nextReaction === "-") { - dislikeCount += 1; - } - - const updatedCounts = { - ...previousCounts, - "+": likeCount, - "-": dislikeCount, - }; - - let updatedTotal = likeCount + dislikeCount; - for (const value of Object.values(otherCounts)) { - updatedTotal += this.normalizeReactionCount(value); - } - - this.modalReactionState = { - counts: updatedCounts, - total: updatedTotal, - userReaction: nextReaction, - }; - - if (this.videoModal?.updateReactionSummary) { - this.videoModal.updateReactionSummary({ - total: updatedTotal, - counts: updatedCounts, - userReaction: nextReaction, - }); - } - - const fallbackPreviousTotal = Number.isFinite(previousTotalValue) - ? previousTotalValue - : likeBefore + - dislikeBefore + - Object.values(otherCounts).reduce( - (sum, value) => sum + this.normalizeReactionCount(value), - 0 - ); - - return { - counts: previousCounts, - total: fallbackPreviousTotal, - userReaction: previousReaction, - }; - } - - restoreModalReactionSnapshot(snapshot) { - if (!snapshot) { - return; - } - - const countsInput = - snapshot.counts && typeof snapshot.counts === "object" - ? snapshot.counts - : {}; - const counts = { ...countsInput }; - for (const [key, value] of Object.entries(counts)) { - counts[key] = this.normalizeReactionCount(value); - } - if (!Object.prototype.hasOwnProperty.call(counts, "+")) { - counts["+"] = 0; - } - if (!Object.prototype.hasOwnProperty.call(counts, "-")) { - counts["-"] = 0; - } - - let total = Number.isFinite(snapshot.total) - ? Math.max(0, Number(snapshot.total)) - : 0; - if (!Number.isFinite(total) || total === 0) { - total = 0; - for (const value of Object.values(counts)) { - total += this.normalizeReactionCount(value); - } - } - - const userReaction = - snapshot.userReaction === "+" - ? "+" - : snapshot.userReaction === "-" - ? "-" - : ""; - - this.modalReactionState = { - counts, - total, - userReaction, - }; - - if (this.videoModal?.updateReactionSummary) { - this.videoModal.updateReactionSummary({ - total, - counts, - userReaction, - }); - } - } - async handleVideoReaction(detail = {}) { - if (!this.videoModal) { - return; - } - - const requestedReaction = - typeof detail.reaction === "string" ? detail.reaction : ""; - const normalizedReaction = - requestedReaction === "+" - ? "+" - : requestedReaction === "-" - ? "-" - : ""; - - if (!normalizedReaction) { - return; - } - - const previousReaction = this.modalReactionState?.userReaction || ""; - const pointer = this.currentVideoPointer; - const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer); - if (!pointer || !pointerKey) { - if (this.videoModal) { - this.videoModal.setUserReaction(previousReaction); - } - devLogger.info( - "[reaction] Ignoring reaction request until modal pointer is available.", - ); - return; - } - if (normalizedReaction === previousReaction) { - return; - } - - if (!this.isUserLoggedIn()) { - this.showError("Please login to react to videos."); - this.videoModal.setUserReaction(previousReaction); - return; - } - - let rollbackSnapshot = null; - try { - rollbackSnapshot = this.applyModalReactionOptimisticUpdate( - normalizedReaction - ); - } catch (error) { - devLogger.warn("[reaction] Failed to apply optimistic reaction state:", error); - } - - try { - const result = await reactionCounter.publish(pointer, { - content: normalizedReaction, - video: this.currentVideo, - currentVideoPubkey: this.currentVideo?.pubkey, - pointerKey, - }); - - if (!result?.ok) { - if (rollbackSnapshot) { - this.restoreModalReactionSnapshot(rollbackSnapshot); - } - this.showError("Failed to send reaction. Please try again."); - } - } catch (error) { - devLogger.warn("[reaction] Failed to publish reaction:", error); - if (rollbackSnapshot) { - this.restoreModalReactionSnapshot(rollbackSnapshot); - } - this.showError("Failed to send reaction. Please try again."); + if (this.reactionController) { + return this.reactionController.handleReaction(detail); } } @@ -4773,7 +4482,9 @@ class Application { this.playbackService.cleanupWatchdog(); } this.teardownModalViewCountSubscription(); - this.teardownModalReactionSubscription(); + if (this.reactionController) { + this.reactionController.unsubscribe(); + } if (!preserveObservers && this.mediaLoader) { this.mediaLoader.disconnect(); @@ -5444,7 +5155,9 @@ class Application { this.cancelPendingViewLogging(); this.clearActiveIntervals(); this.teardownModalViewCountSubscription(); - this.teardownModalReactionSubscription(); + if (this.reactionController) { + this.reactionController.unsubscribe(); + } this.pendingModeratedPlayback = null; if ( this.videoModal && @@ -8981,7 +8694,7 @@ class Application { this.currentVideoPointer, this.currentVideoPointerKey ); - this.subscribeModalReactions( + this.reactionController.subscribe( this.currentVideoPointer, this.currentVideoPointerKey ); @@ -9320,7 +9033,7 @@ class Application { this.currentVideoPointer = null; this.currentVideoPointerKey = null; this.subscribeModalViewCount(null, null); - this.subscribeModalReactions(null, null); + this.reactionController.subscribe(null, null); this.pendingModeratedPlayback = null; const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; diff --git a/js/ui/reactionController.js b/js/ui/reactionController.js new file mode 100644 index 00000000..b9cb1ac8 --- /dev/null +++ b/js/ui/reactionController.js @@ -0,0 +1,371 @@ +import { devLogger } from "../utils/logger.js"; +import { pointerArrayToKey } from "../utils/pointer.js"; + +export default class ReactionController { + constructor({ services = {}, ui = {}, state = {}, callbacks = {} } = {}) { + this.services = { + reactionCounter: services.reactionCounter, + }; + this.ui = { + getVideoModal: ui.getVideoModal || (() => null), + showError: ui.showError || (() => {}), + }; + this.state = { + getCurrentVideo: state.getCurrentVideo || (() => null), + getCurrentVideoPointer: state.getCurrentVideoPointer || (() => null), + getCurrentVideoPointerKey: state.getCurrentVideoPointerKey || (() => null), + }; + this.callbacks = { + isUserLoggedIn: callbacks.isUserLoggedIn || (() => false), + normalizeHexPubkey: callbacks.normalizeHexPubkey || ((val) => val), + getPubkey: callbacks.getPubkey || (() => null), + }; + + this.reactionState = { + counts: { "+": 0, "-": 0 }, + total: 0, + userReaction: "", + }; + this.unsub = null; + this.pointerKey = null; + } + + normalizeReactionCount(value) { + if (Number.isFinite(value)) { + return Math.max(0, Math.round(Number(value))); + } + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : 0; + } + + resetState() { + this.reactionState = { + counts: { "+": 0, "-": 0 }, + total: 0, + userReaction: "", + }; + const videoModal = this.ui.getVideoModal(); + if (videoModal?.updateReactionSummary) { + videoModal.updateReactionSummary({ + total: 0, + counts: { "+": 0, "-": 0 }, + userReaction: "", + }); + } + } + + unsubscribe() { + if (typeof this.unsub === "function") { + try { + this.unsub(); + } catch (error) { + devLogger.warn( + "[reaction] Failed to tear down modal subscription:", + error, + ); + } + } + this.unsub = null; + this.pointerKey = null; + this.resetState(); + } + + subscribe(pointer, pointerKey) { + const videoModal = this.ui.getVideoModal(); + if (!videoModal?.updateReactionSummary) { + this.unsubscribe(); + return; + } + + this.unsubscribe(); + + if (!pointer || !pointerKey) { + return; + } + + try { + const normalizedUser = this.callbacks.normalizeHexPubkey(this.callbacks.getPubkey()); + const unsubscribe = this.services.reactionCounter.subscribe(pointer, (snapshot) => { + const counts = { ...this.reactionState.counts }; + if (snapshot?.counts && typeof snapshot.counts === "object") { + for (const [key, value] of Object.entries(snapshot.counts)) { + counts[key] = this.normalizeReactionCount(value); + } + } + if (!Object.prototype.hasOwnProperty.call(counts, "+")) { + counts["+"] = 0; + } + if (!Object.prototype.hasOwnProperty.call(counts, "-")) { + counts["-"] = 0; + } + + let total = Number.isFinite(snapshot?.total) + ? Math.max(0, Number(snapshot.total)) + : 0; + if (!Number.isFinite(total) || total === 0) { + total = 0; + for (const value of Object.values(counts)) { + total += this.normalizeReactionCount(value); + } + } + + let userReaction = ""; + if (normalizedUser && snapshot?.reactions) { + const record = snapshot.reactions[normalizedUser] || null; + if (record && typeof record.content === "string") { + userReaction = + record.content === "+" + ? "+" + : record.content === "-" + ? "-" + : ""; + } + } + + this.reactionState = { + counts, + total, + userReaction, + }; + + const currentModal = this.ui.getVideoModal(); + if (currentModal?.updateReactionSummary) { + currentModal.updateReactionSummary({ + total, + counts, + userReaction, + }); + } + }); + + this.pointerKey = pointerKey; + this.unsub = () => { + try { + unsubscribe?.(); + } catch (error) { + devLogger.warn( + "[reaction] Failed to tear down modal subscription:", + error, + ); + } finally { + this.unsub = null; + this.pointerKey = null; + } + }; + } catch (error) { + devLogger.warn( + "[reaction] Failed to subscribe modal reaction counter:", + error, + ); + this.resetState(); + } + } + + applyOptimisticUpdate(nextReaction) { + if (nextReaction !== "+" && nextReaction !== "-") { + return null; + } + + const previousCounts = { + ...(this.reactionState?.counts || {}), + }; + const previousTotalValue = Number.isFinite(this.reactionState?.total) + ? Math.max(0, Number(this.reactionState.total)) + : null; + const previousReaction = this.reactionState?.userReaction || ""; + + const likeBefore = this.normalizeReactionCount(previousCounts["+"]); + const dislikeBefore = this.normalizeReactionCount(previousCounts["-"]); + const otherCounts = {}; + for (const [key, value] of Object.entries(previousCounts)) { + if (key === "+" || key === "-") { + continue; + } + otherCounts[key] = this.normalizeReactionCount(value); + } + + let likeCount = likeBefore; + let dislikeCount = dislikeBefore; + + if (previousReaction === "+") { + likeCount = Math.max(0, likeCount - 1); + } else if (previousReaction === "-") { + dislikeCount = Math.max(0, dislikeCount - 1); + } + + if (nextReaction === "+") { + likeCount += 1; + } else if (nextReaction === "-") { + dislikeCount += 1; + } + + const updatedCounts = { + ...previousCounts, + "+": likeCount, + "-": dislikeCount, + }; + + let updatedTotal = likeCount + dislikeCount; + for (const value of Object.values(otherCounts)) { + updatedTotal += this.normalizeReactionCount(value); + } + + this.reactionState = { + counts: updatedCounts, + total: updatedTotal, + userReaction: nextReaction, + }; + + const videoModal = this.ui.getVideoModal(); + if (videoModal?.updateReactionSummary) { + videoModal.updateReactionSummary({ + total: updatedTotal, + counts: updatedCounts, + userReaction: nextReaction, + }); + } + + const fallbackPreviousTotal = Number.isFinite(previousTotalValue) + ? previousTotalValue + : likeBefore + + dislikeBefore + + Object.values(otherCounts).reduce( + (sum, value) => sum + this.normalizeReactionCount(value), + 0 + ); + + return { + counts: previousCounts, + total: fallbackPreviousTotal, + userReaction: previousReaction, + }; + } + + restoreSnapshot(snapshot) { + if (!snapshot) { + return; + } + + const countsInput = + snapshot.counts && typeof snapshot.counts === "object" + ? snapshot.counts + : {}; + const counts = { ...countsInput }; + for (const [key, value] of Object.entries(counts)) { + counts[key] = this.normalizeReactionCount(value); + } + if (!Object.prototype.hasOwnProperty.call(counts, "+")) { + counts["+"] = 0; + } + if (!Object.prototype.hasOwnProperty.call(counts, "-")) { + counts["-"] = 0; + } + + let total = Number.isFinite(snapshot.total) + ? Math.max(0, Number(snapshot.total)) + : 0; + if (!Number.isFinite(total) || total === 0) { + total = 0; + for (const value of Object.values(counts)) { + total += this.normalizeReactionCount(value); + } + } + + const userReaction = + snapshot.userReaction === "+" + ? "+" + : snapshot.userReaction === "-" + ? "-" + : ""; + + this.reactionState = { + counts, + total, + userReaction, + }; + + const videoModal = this.ui.getVideoModal(); + if (videoModal?.updateReactionSummary) { + videoModal.updateReactionSummary({ + total, + counts, + userReaction, + }); + } + } + + async handleReaction(detail = {}) { + const videoModal = this.ui.getVideoModal(); + if (!videoModal) { + return; + } + + const requestedReaction = + typeof detail.reaction === "string" ? detail.reaction : ""; + const normalizedReaction = + requestedReaction === "+" + ? "+" + : requestedReaction === "-" + ? "-" + : ""; + + if (!normalizedReaction) { + return; + } + + const previousReaction = this.reactionState?.userReaction || ""; + const pointer = this.state.getCurrentVideoPointer(); + const pointerKey = this.state.getCurrentVideoPointerKey() || (pointer ? pointerArrayToKey(pointer) : null); + + if (!pointer || !pointerKey) { + if (videoModal) { + videoModal.setUserReaction(previousReaction); + } + devLogger.info( + "[reaction] Ignoring reaction request until modal pointer is available.", + ); + return; + } + if (normalizedReaction === previousReaction) { + return; + } + + if (!this.callbacks.isUserLoggedIn()) { + this.ui.showError("Please login to react to videos."); + videoModal.setUserReaction(previousReaction); + return; + } + + let rollbackSnapshot = null; + try { + rollbackSnapshot = this.applyOptimisticUpdate( + normalizedReaction + ); + } catch (error) { + devLogger.warn("[reaction] Failed to apply optimistic reaction state:", error); + } + + try { + const currentVideo = this.state.getCurrentVideo(); + const result = await this.services.reactionCounter.publish(pointer, { + content: normalizedReaction, + video: currentVideo, + currentVideoPubkey: currentVideo?.pubkey, + pointerKey, + }); + + if (!result?.ok) { + if (rollbackSnapshot) { + this.restoreSnapshot(rollbackSnapshot); + } + this.ui.showError("Failed to send reaction. Please try again."); + } + } catch (error) { + devLogger.warn("[reaction] Failed to publish reaction:", error); + if (rollbackSnapshot) { + this.restoreSnapshot(rollbackSnapshot); + } + this.ui.showError("Failed to send reaction. Please try again."); + } + } +} diff --git a/tests/ui/reactionController.test.mjs b/tests/ui/reactionController.test.mjs new file mode 100644 index 00000000..40128610 --- /dev/null +++ b/tests/ui/reactionController.test.mjs @@ -0,0 +1,154 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert"; +import ReactionController from "../../js/ui/reactionController.js"; + +describe("ReactionController", () => { + let controller; + let mockReactionCounter; + let mockVideoModal; + let mockState; + let mockCallbacks; + let mockUi; + + beforeEach(() => { + mockReactionCounter = { + subscribe: (pointer, cb) => { + return () => {}; + }, + publish: async () => ({ ok: true }), + }; + + mockVideoModal = { + updateReactionSummary: () => {}, + setUserReaction: () => {}, + }; + + mockUi = { + getVideoModal: () => mockVideoModal, + showError: () => {}, + }; + + mockState = { + getCurrentVideo: () => ({ id: "v1", pubkey: "p1" }), + getCurrentVideoPointer: () => ["e", "v1"], + getCurrentVideoPointerKey: () => "e:v1", + }; + + mockCallbacks = { + isUserLoggedIn: () => true, + normalizeHexPubkey: (val) => val, + getPubkey: () => "user1", + }; + + controller = new ReactionController({ + services: { reactionCounter: mockReactionCounter }, + ui: mockUi, + state: mockState, + callbacks: mockCallbacks, + }); + }); + + it("should subscribe to reactions", (t) => { + const subscribeMock = t.mock.method(mockReactionCounter, "subscribe"); + + controller.subscribe(["e", "v1"], "e:v1"); + + assert.strictEqual(subscribeMock.mock.callCount(), 1); + assert.deepStrictEqual(subscribeMock.mock.calls[0].arguments[0], ["e", "v1"]); + }); + + it("should handle reaction update from subscription", (t) => { + let subCallback; + mockReactionCounter.subscribe = (pointer, cb) => { + subCallback = cb; + return () => {}; + }; + const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary"); + + controller.subscribe(["e", "v1"], "e:v1"); + // subscribe calls unsubscribe which calls resetState which calls updateReactionSummary (1st call) + + subCallback({ + total: 10, + counts: { "+": 10 }, + reactions: { "user1": { content: "+" } } + }); + // subCallback calls updateReactionSummary (2nd call) + + assert.strictEqual(updateSummaryMock.mock.callCount(), 2); + assert.deepStrictEqual(updateSummaryMock.mock.calls[1].arguments[0], { + total: 10, + counts: { "+": 10, "-": 0 }, + userReaction: "+" + }); + }); + + it("should apply optimistic update on handleReaction", async (t) => { + const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary"); + const publishMock = t.mock.method(mockReactionCounter, "publish"); + + // Pre-seed state + controller.reactionState = { + total: 5, + counts: { "+": 5, "-": 0 }, + userReaction: "" + }; + + await controller.handleReaction({ reaction: "+" }); + + // Verify optimistic update call + // Should increment + count and total + assert.strictEqual(updateSummaryMock.mock.callCount(), 1); + const updateArgs = updateSummaryMock.mock.calls[0].arguments[0]; + assert.strictEqual(updateArgs.total, 6); + assert.strictEqual(updateArgs.counts["+"], 6); + assert.strictEqual(updateArgs.userReaction, "+"); + + // Verify publish call + assert.strictEqual(publishMock.mock.callCount(), 1); + }); + + it("should rollback optimistic update on publish failure", async (t) => { + mockReactionCounter.publish = async () => ({ ok: false }); + const updateSummaryMock = t.mock.method(mockVideoModal, "updateReactionSummary"); + + // We need to update controller.ui.showError because constructor copied the reference + const showErrorMock = t.mock.method(mockUi, "showError"); + controller.ui.showError = showErrorMock; + + controller.reactionState = { + total: 5, + counts: { "+": 5, "-": 0 }, + userReaction: "" + }; + + await controller.handleReaction({ reaction: "+" }); + + // 1st call: optimistic update + // 2nd call: restore snapshot + assert.strictEqual(updateSummaryMock.mock.callCount(), 2); + + // Restore check + const restoreArgs = updateSummaryMock.mock.calls[1].arguments[0]; + assert.strictEqual(restoreArgs.total, 5); + assert.strictEqual(restoreArgs.counts["+"], 5); + assert.strictEqual(restoreArgs.userReaction, ""); + + assert.strictEqual(showErrorMock.mock.callCount(), 1); + }); + + it("should not react if user not logged in", async (t) => { + // Update callback on controller instance + controller.callbacks.isUserLoggedIn = () => false; + + const showErrorMock = t.mock.method(mockUi, "showError"); + controller.ui.showError = showErrorMock; + + const publishMock = t.mock.method(mockReactionCounter, "publish"); + + await controller.handleReaction({ reaction: "+" }); + + assert.strictEqual(showErrorMock.mock.callCount(), 1); + assert.strictEqual(publishMock.mock.callCount(), 0); + }); +});