diff --git a/src/channel.ts b/src/channel.ts index 2117816fe..365c404f4 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1079,6 +1079,8 @@ export class Channel, - poll: PollResponse, - messageId: string, - ) => { - const message = this.findMessage(messageId); - if (!message) return; - - if (message.poll_id !== pollVote.poll_id) return; - - const updatedPoll = { ...poll }; - let ownVotes = [...(message.poll?.own_votes || [])]; - - if (pollVote.user_id === this._channel.getClient().userID) { - if (pollVote.option_id && poll.enforce_unique_vote) { - // remove all previous votes where option_id is not empty - ownVotes = ownVotes.filter((vote) => !vote.option_id); - } else if (pollVote.answer_text) { - // remove all previous votes where option_id is empty - ownVotes = ownVotes.filter((vote) => vote.answer_text); - } - - ownVotes.push(pollVote); - } - - updatedPoll.own_votes = ownVotes as PollVote[]; - const newMessage = { ...message, poll: updatedPoll }; - - this.addMessageSorted((newMessage as unknown) as MessageResponse, false, false); - }; - - addPollVote = (pollVote: PollVote, poll: PollResponse, messageId: string) => { - const message = this.findMessage(messageId); - if (!message) return; - - if (message.poll_id !== pollVote.poll_id) return; - - const updatedPoll = { ...poll }; - const ownVotes = [...(message.poll?.own_votes || [])]; - - if (pollVote.user_id === this._channel.getClient().userID) { - ownVotes.push(pollVote); - } - - updatedPoll.own_votes = ownVotes as PollVote[]; - const newMessage = { ...message, poll: updatedPoll }; - - this.addMessageSorted((newMessage as unknown) as MessageResponse, false, false); - }; - - removePollVote = ( - pollVote: PollVote, - poll: PollResponse, - messageId: string, - ) => { - const message = this.findMessage(messageId); - if (!message) return; - - if (message.poll_id !== pollVote.poll_id) return; - - const updatedPoll = { ...poll }; - const ownVotes = [...(message.poll?.own_votes || [])]; - if (pollVote.user_id === this._channel.getClient().userID) { - const index = ownVotes.findIndex((vote) => vote.option_id === pollVote.option_id); - if (index > -1) { - ownVotes.splice(index, 1); - } - } - - updatedPoll.own_votes = ownVotes as PollVote[]; - - const newMessage = { ...message, poll: updatedPoll }; - this.addMessageSorted((newMessage as unknown) as MessageResponse, false, false); - }; - - updatePoll = (poll: PollResponse, messageId: string) => { - const message = this.findMessage(messageId); - if (!message) return; - - const updatedPoll = { - ...poll, - own_votes: [...(message.poll?.own_votes || [])], - }; - - const newMessage = { ...message, poll: updatedPoll }; - - this.addMessageSorted((newMessage as unknown) as MessageResponse, false, false); - }; - /** * Updates the message.user property with updated user object, for messages. * diff --git a/src/client.ts b/src/client.ts index 38af3c5c6..901673091 100644 --- a/src/client.ts +++ b/src/client.ts @@ -42,10 +42,12 @@ import { BlockList, BlockListResponse, BlockUserAPIResponse, - CampaignResponse, CampaignData, CampaignFilters, CampaignQueryOptions, + CampaignResponse, + CampaignSort, + CastVoteAPIResponse, ChannelAPIResponse, ChannelData, ChannelFilters, @@ -66,6 +68,9 @@ import { CreateImportOptions, CreateImportResponse, CreateImportURLResponse, + CreatePollAPIResponse, + CreatePollData, + CreatePollOptionAPIResponse, CustomPermissionOptions, DeactivateUsersOptions, DefaultGenerics, @@ -92,13 +97,18 @@ import { FlagsPaginationOptions, FlagsResponse, FlagUserResponse, + GetBlockedUsersAPIResponse, GetCallTokenResponse, GetChannelTypeResponse, GetCommandResponse, GetImportResponse, GetMessageAPIResponse, + GetMessageOptions, + GetPollAPIResponse, + GetPollOptionAPIResponse, GetRateLimitsResponse, - QueryThreadsAPIResponse, + GetThreadAPIResponse, + GetThreadOptions, GetUnreadCountAPIResponse, GetUnreadCountBatchAPIResponse, ListChannelResponse, @@ -120,11 +130,15 @@ import { OwnUserResponse, PartialMessageUpdate, PartialPollUpdate, + PartialThreadUpdate, PartialUserUpdate, PermissionAPIResponse, PermissionsAPIResponse, + PollAnswersAPIResponse, PollData, PollOptionData, + PollSort, + PollVote, PollVoteData, PollVotesAPIResponse, PushProvider, @@ -133,9 +147,24 @@ import { PushProviderListResponse, PushProviderUpsertResponse, QueryChannelsAPIResponse, - QuerySegmentsOptions, + QueryMessageHistoryFilters, + QueryMessageHistoryOptions, + QueryMessageHistoryResponse, + QueryMessageHistorySort, + QueryPollsFilters, + QueryPollsOptions, QueryPollsResponse, + QueryReactionsAPIResponse, + QueryReactionsOptions, + QuerySegmentsOptions, + QuerySegmentTargetsFilter, + QueryThreadsAPIResponse, + QueryThreadsOptions, + QueryVotesFilters, + QueryVotesOptions, + ReactionFilters, ReactionResponse, + ReactionSort, ReactivateUserOptions, ReactivateUsersOptions, ReservedMessageFields, @@ -145,10 +174,12 @@ import { SearchMessageSortBase, SearchOptions, SearchPayload, - SegmentResponse, SegmentData, + SegmentResponse, + SegmentTargetsResponse, SegmentType, SendFileAPIResponse, + SortParam, StreamChatOptions, SyncOptions, SyncResponse, @@ -166,50 +197,22 @@ import { UpdatedMessage, UpdateMessageAPIResponse, UpdateMessageOptions, + UpdatePollAPIResponse, + UpdatePollOptionAPIResponse, UpdateSegmentData, UserCustomEvent, UserFilters, UserOptions, UserResponse, UserSort, - GetThreadAPIResponse, - PartialThreadUpdate, - QueryThreadsOptions, - GetThreadOptions, - CampaignSort, - SegmentTargetsResponse, - QuerySegmentTargetsFilter, - SortParam, - GetMessageOptions, - GetBlockedUsersAPIResponse, - QueryVotesFilters, VoteSort, - CreatePollAPIResponse, - GetPollAPIResponse, - UpdatePollAPIResponse, - CreatePollOptionAPIResponse, - GetPollOptionAPIResponse, - UpdatePollOptionAPIResponse, - PollVote, - CastVoteAPIResponse, - QueryPollsFilters, - PollSort, - QueryPollsOptions, - QueryVotesOptions, - ReactionFilters, - ReactionSort, - QueryReactionsAPIResponse, - QueryReactionsOptions, - QueryMessageHistoryFilters, - QueryMessageHistorySort, - QueryMessageHistoryOptions, - QueryMessageHistoryResponse, } from './types'; import { InsightMetrics, postInsights } from './insights'; import { Thread } from './thread'; import { Moderation } from './moderation'; import { ThreadManager } from './thread_manager'; import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from './constants'; +import { PollManager } from './poll_manager'; function isString(x: unknown): x is string { return typeof x === 'string' || x instanceof String; @@ -223,6 +226,7 @@ export class StreamChat; }; threads: ThreadManager; + polls: PollManager; anonymous: boolean; persistUserOnConnectionFailure?: boolean; axiosInstance: AxiosInstance; @@ -410,6 +414,7 @@ export class StreamChat null; this.recoverStateOnReconnect = this.options.recoverStateOnReconnect; this.threads = new ThreadManager({ client: this }); + this.polls = new PollManager({ client: this }); } /** @@ -1676,6 +1681,7 @@ export class StreamChat(this.baseURL + `/polls`, { + async createPoll(poll: CreatePollData, userId?: string) { + return await this.post>(this.baseURL + `/polls`, { ...poll, ...(userId ? { user_id: userId } : {}), }); @@ -3561,8 +3567,8 @@ export class StreamChat { - return await this.get( + async getPoll(id: string, userId?: string): Promise> { + return await this.get>( this.baseURL + `/polls/${encodeURIComponent(id)}`, userId ? { user_id: userId } : {}, ); @@ -3574,8 +3580,8 @@ export class StreamChat(this.baseURL + `/polls`, { + async updatePoll(poll: PollData, userId?: string) { + return await this.put>(this.baseURL + `/polls`, { ...poll, ...(userId ? { user_id: userId } : {}), }); @@ -3591,13 +3597,16 @@ export class StreamChat, userId?: string, - ): Promise { - return await this.patch(this.baseURL + `/polls/${encodeURIComponent(id)}`, { - ...partialPollObject, - ...(userId ? { user_id: userId } : {}), - }); + ): Promise> { + return await this.patch>( + this.baseURL + `/polls/${encodeURIComponent(id)}`, + { + ...partialPollObject, + ...(userId ? { user_id: userId } : {}), + }, + ); } /** @@ -3618,13 +3627,16 @@ export class StreamChat { - return this.partialUpdatePoll(id, { - set: { - is_closed: true, + async closePoll(id: string, userId?: string): Promise> { + return this.partialUpdatePoll( + id, + { + set: { + is_closed: true, + } as PartialPollUpdate['set'], }, - ...(userId ? { user_id: userId } : {}), - }); + userId, + ); } /** @@ -3634,8 +3646,8 @@ export class StreamChat( + async createPollOption(pollId: string, option: PollOptionData, userId?: string) { + return await this.post>( this.baseURL + `/polls/${encodeURIComponent(pollId)}/options`, { ...option, @@ -3652,7 +3664,7 @@ export class StreamChat( + return await this.get>( this.baseURL + `/polls/${encodeURIComponent(pollId)}/options/${encodeURIComponent(optionId)}`, userId ? { user_id: userId } : {}, ); @@ -3665,8 +3677,8 @@ export class StreamChat( + async updatePollOption(pollId: string, option: PollOptionData, userId?: string) { + return await this.put>( this.baseURL + `/polls/${encodeURIComponent(pollId)}/options`, { ...option, @@ -3698,7 +3710,7 @@ export class StreamChat( + return await this.post>( this.baseURL + `/messages/${encodeURIComponent(messageId)}/polls/${encodeURIComponent(pollId)}/vote`, { vote, @@ -3750,9 +3762,9 @@ export class StreamChat { + ): Promise> { const q = userId ? `?user_id=${userId}` : ''; - return await this.post(this.baseURL + `/polls/query${q}`, { + return await this.post>(this.baseURL + `/polls/query${q}`, { filter, sort: normalizeQuerySort(sort), ...options, @@ -3774,9 +3786,9 @@ export class StreamChat { + ): Promise> { const q = userId ? `?user_id=${userId}` : ''; - return await this.post( + return await this.post>( this.baseURL + `/polls/${encodeURIComponent(pollId)}/votes${q}`, { filter, @@ -3786,6 +3798,33 @@ export class StreamChat> { + const q = userId ? `?user_id=${userId}` : ''; + return await this.post>( + this.baseURL + `/polls/${encodeURIComponent(pollId)}/votes${q}`, + { + filter: { ...filter, is_answer: true }, + sort: normalizeQuerySort(sort), + ...options, + }, + ); + } + /** * Query message history * @param filter @@ -3797,12 +3836,15 @@ export class StreamChat { - return await this.post(this.baseURL + '/messages/history', { - filter, - sort: normalizeQuerySort(sort), - ...options, - }); + ): Promise> { + return await this.post>( + this.baseURL + '/messages/history', + { + filter, + sort: normalizeQuerySort(sort), + ...options, + }, + ); } /** diff --git a/src/index.ts b/src/index.ts index 64c3c9231..c0d0901f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,21 @@ export * from './base64'; +export * from './campaign'; export * from './client'; export * from './client_state'; export * from './channel'; export * from './channel_state'; -export * from './thread'; -export * from './thread_manager'; export * from './connection'; export * from './events'; +export * from './insights'; export * from './moderation'; export * from './permissions'; +export * from './poll'; +export * from './poll_manager'; +export * from './segment'; export * from './signing'; +export * from './store'; +export * from './thread'; +export * from './thread_manager'; export * from './token_manager'; -export * from './insights'; export * from './types'; -export * from './segment'; -export * from './campaign'; export { isOwnUser, chatCodes, logChatPromiseExecution, formatMessage } from './utils'; -export * from './store'; diff --git a/src/poll.ts b/src/poll.ts new file mode 100644 index 000000000..aad89abde --- /dev/null +++ b/src/poll.ts @@ -0,0 +1,414 @@ +import { StateStore } from './store'; +import type { StreamChat } from './client'; +import type { + DefaultGenerics, + Event, + ExtendableGenerics, + PartialPollUpdate, + PollAnswer, + PollData, + PollEnrichData, + PollOptionData, + PollResponse, + PollVote, + QueryVotesFilters, + QueryVotesOptions, + VoteSort, +} from './types'; + +type PollEvent = { + cid: string; + created_at: string; + poll: PollResponse; +}; + +type PollUpdatedEvent = PollEvent & { + type: 'poll.updated'; +}; + +type PollClosedEvent = PollEvent & { + type: 'poll.closed'; +}; + +type PollVoteEvent = { + cid: string; + created_at: string; + poll: PollResponse; + poll_vote: PollVote | PollAnswer; +}; + +type PollVoteCastedEvent = PollVoteEvent & { + type: 'poll.vote_casted'; +}; + +type PollVoteCastedChanged = PollVoteEvent & { + type: 'poll.vote_removed'; +}; + +type PollVoteCastedRemoved = PollVoteEvent & { + type: 'poll.vote_removed'; +}; + +const isPollUpdatedEvent = ( + e: Event, +): e is PollUpdatedEvent => e.type === 'poll.updated'; +const isPollClosedEventEvent = ( + e: Event, +): e is PollClosedEvent => e.type === 'poll.closed'; +const isPollVoteCastedEvent = ( + e: Event, +): e is PollVoteCastedEvent => e.type === 'poll.vote_casted'; +const isPollVoteChangedEvent = ( + e: Event, +): e is PollVoteCastedChanged => e.type === 'poll.vote_changed'; +const isPollVoteRemovedEvent = ( + e: Event, +): e is PollVoteCastedRemoved => e.type === 'poll.vote_removed'; + +export const isVoteAnswer = ( + vote: PollVote | PollAnswer, +): vote is PollAnswer => !!(vote as PollAnswer)?.answer_text; + +export type PollAnswersQueryParams = { + filter?: QueryVotesFilters; + options?: QueryVotesOptions; + sort?: VoteSort; +}; + +export type PollOptionVotesQueryParams = { + filter: { option_id: string } & QueryVotesFilters; + options?: QueryVotesOptions; + sort?: VoteSort; +}; + +type OptionId = string; + +export type PollState = SCG['pollType'] & + Omit, 'own_votes' | 'id'> & { + lastActivityAt: Date; // todo: would be ideal to get this from the BE + maxVotedOptionIds: OptionId[]; + ownVotesByOptionId: Record>; + ownAnswer?: PollAnswer; // each user can have only one answer + }; + +type PollInitOptions = { + client: StreamChat; + poll: PollResponse; +}; + +export class Poll { + public readonly state: StateStore>; + public id: string; + private client: StreamChat; + private unsubscribeFunctions: Set<() => void> = new Set(); + + constructor({ client, poll }: PollInitOptions) { + this.client = client; + this.id = poll.id; + + this.state = new StateStore>(this.getInitialStateFromPollResponse(poll)); + } + + private getInitialStateFromPollResponse = (poll: PollInitOptions['poll']) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { own_votes, id, ...pollResponseForState } = poll; + const { ownAnswer, ownVotes } = own_votes?.reduce<{ ownVotes: PollVote[]; ownAnswer?: PollAnswer }>( + (acc, voteOrAnswer) => { + if (isVoteAnswer(voteOrAnswer)) { + acc.ownAnswer = voteOrAnswer; + } else { + acc.ownVotes.push(voteOrAnswer); + } + return acc; + }, + { ownVotes: [] }, + ) ?? { ownVotes: [] }; + + return { + ...pollResponseForState, + lastActivityAt: new Date(), + maxVotedOptionIds: getMaxVotedOptionIds( + pollResponseForState.vote_counts_by_option as PollResponse['vote_counts_by_option'], + ), + ownAnswer, + ownVotesByOptionId: getOwnVotesByOptionId(ownVotes), + }; + }; + + public reinitializeState = (poll: PollInitOptions['poll']) => { + this.state.partialNext(this.getInitialStateFromPollResponse(poll)); + }; + + get data(): PollState { + return this.state.getLatestValue(); + } + + public handlePollUpdated = (event: Event) => { + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollUpdatedEvent(event)) return; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, ...pollData } = extractPollData(event.poll); + // @ts-ignore + this.state.partialNext({ ...pollData, lastActivityAt: new Date(event.created_at) }); + }; + + public handlePollClosed = (event: Event) => { + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollClosedEventEvent(event)) return; + // @ts-ignore + this.state.partialNext({ is_closed: true, lastActivityAt: new Date(event.created_at) }); + }; + + public handleVoteCasted = (event: Event) => { + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollVoteCastedEvent(event)) return; + const currentState = this.data; + const isOwnVote = event.poll_vote.user_id === this.client.userID; + let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + let ownAnswer = currentState.ownAnswer; + const ownVotesByOptionId = currentState.ownVotesByOptionId; + let maxVotedOptionIds = currentState.maxVotedOptionIds; + + if (isOwnVote) { + if (isVoteAnswer(event.poll_vote)) { + ownAnswer = event.poll_vote; + } else if (event.poll_vote.option_id) { + ownVotesByOptionId[event.poll_vote.option_id] = event.poll_vote; + } + } + + if (isVoteAnswer(event.poll_vote)) { + latestAnswers = [event.poll_vote, ...latestAnswers]; + } else { + maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); + } + + const pollEnrichData = extractPollEnrichedData(event.poll); + // @ts-ignore + this.state.partialNext({ + ...pollEnrichData, + latest_answers: latestAnswers, + lastActivityAt: new Date(event.created_at), + ownAnswer, + ownVotesByOptionId, + maxVotedOptionIds, + }); + }; + + public handleVoteChanged = (event: Event) => { + // this event is triggered only when event.poll.enforce_unique_vote === true + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollVoteChangedEvent(event)) return; + const currentState = this.data; + const isOwnVote = event.poll_vote.user_id === this.client.userID; + let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + let ownAnswer = currentState.ownAnswer; + let ownVotesByOptionId = currentState.ownVotesByOptionId; + let maxVotedOptionIds = currentState.maxVotedOptionIds; + + if (isOwnVote) { + if (isVoteAnswer(event.poll_vote)) { + latestAnswers = [event.poll_vote, ...latestAnswers.filter((answer) => answer.id !== event.poll_vote.id)]; + ownAnswer = event.poll_vote; + } else if (event.poll_vote.option_id) { + if (event.poll.enforce_unique_votes) { + ownVotesByOptionId = { [event.poll_vote.option_id]: event.poll_vote }; + } else { + ownVotesByOptionId = Object.entries(ownVotesByOptionId).reduce>>( + (acc, [optionId, vote]) => { + if (optionId !== event.poll_vote.option_id && vote.id === event.poll_vote.id) { + return acc; + } + acc[optionId] = vote; + return acc; + }, + {}, + ); + ownVotesByOptionId[event.poll_vote.option_id] = event.poll_vote; + } + + if (ownAnswer?.id === event.poll_vote.id) { + ownAnswer = undefined; + } + maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); + } + } else if (isVoteAnswer(event.poll_vote)) { + latestAnswers = [event.poll_vote, ...latestAnswers]; + } else { + maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); + } + + const pollEnrichData = extractPollEnrichedData(event.poll); + // @ts-ignore + this.state.partialNext({ + ...pollEnrichData, + latest_answers: latestAnswers, + lastActivityAt: new Date(event.created_at), + ownAnswer, + ownVotesByOptionId, + maxVotedOptionIds, + }); + }; + + public handleVoteRemoved = (event: Event) => { + if (event.poll?.id && event.poll.id !== this.id) return; + if (!isPollVoteRemovedEvent(event)) return; + const currentState = this.data; + const isOwnVote = event.poll_vote.user_id === this.client.userID; + let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + let ownAnswer = currentState.ownAnswer; + const ownVotesByOptionId = { ...currentState.ownVotesByOptionId }; + let maxVotedOptionIds = currentState.maxVotedOptionIds; + + if (isVoteAnswer(event.poll_vote)) { + latestAnswers = latestAnswers.filter((answer) => answer.id !== event.poll_vote.id); + if (isOwnVote) { + ownAnswer = undefined; + } + } else { + maxVotedOptionIds = getMaxVotedOptionIds(event.poll.vote_counts_by_option); + if (isOwnVote && event.poll_vote.option_id) { + delete ownVotesByOptionId[event.poll_vote.option_id]; + } + } + + const pollEnrichData = extractPollEnrichedData(event.poll); + // @ts-ignore + this.state.partialNext({ + ...pollEnrichData, + latest_answers: latestAnswers, + lastActivityAt: new Date(event.created_at), + ownAnswer, + ownVotesByOptionId, + maxVotedOptionIds, + }); + }; + + query = async (id: string) => { + const { poll } = await this.client.getPoll(id); + // @ts-ignore + this.state.partialNext({ ...poll, lastActivityAt: new Date() }); + return poll; + }; + + update = async (data: Exclude, 'id'>) => { + return await this.client.updatePoll({ ...data, id: this.id }); + }; + + partialUpdate = async (partialPollObject: PartialPollUpdate) => { + return await this.client.partialUpdatePoll(this.id as string, partialPollObject); + }; + + close = async () => { + return await this.client.closePoll(this.id as string); + }; + + delete = async () => { + return await this.client.deletePoll(this.id as string); + }; + + createOption = async (option: PollOptionData) => { + return await this.client.createPollOption(this.id as string, option); + }; + + updateOption = async (option: PollOptionData) => { + return await this.client.updatePollOption(this.id as string, option); + }; + + deleteOption = async (optionId: string) => { + return await this.client.deletePollOption(this.id as string, optionId); + }; + + castVote = async (optionId: string, messageId: string) => { + const { max_votes_allowed, ownVotesByOptionId } = this.data; + + const reachedVoteLimit = max_votes_allowed && max_votes_allowed === Object.keys(ownVotesByOptionId).length; + + if (reachedVoteLimit) { + let oldestVote = Object.values(ownVotesByOptionId)[0]; + Object.values(ownVotesByOptionId) + .slice(1) + .forEach((vote) => { + if (!oldestVote?.created_at || new Date(vote.created_at) < new Date(oldestVote.created_at)) { + oldestVote = vote; + } + }); + if (oldestVote?.id) { + await this.removeVote(oldestVote.id, messageId); + } + } + return await this.client.castPollVote(messageId, this.id as string, { option_id: optionId }); + }; + + removeVote = async (voteId: string, messageId: string) => { + return await this.client.removePollVote(messageId, this.id as string, voteId); + }; + + addAnswer = async (answerText: string, messageId: string) => { + return await this.client.addPollAnswer(messageId, this.id as string, answerText); + }; + + removeAnswer = async (answerId: string, messageId: string) => { + return await this.client.removePollVote(messageId, this.id as string, answerId); + }; + + queryAnswers = async (params: PollAnswersQueryParams) => { + return await this.client.queryPollAnswers(this.id as string, params.filter, params.sort, params.options); + }; + + queryOptionVotes = async (params: PollOptionVotesQueryParams) => { + return await this.client.queryPollVotes(this.id as string, params.filter, params.sort, params.options); + }; +} + +function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_option']) { + let maxVotes = 0; + let winningOptions: string[] = []; + for (const [id, count] of Object.entries(voteCountsByOption ?? {})) { + if (count > maxVotes) { + winningOptions = [id]; + maxVotes = count; + } else if (count === maxVotes) { + winningOptions.push(id); + } + } + return winningOptions; +} + +function getOwnVotesByOptionId(ownVotes: PollVote[]) { + return !ownVotes + ? ({} as Record>) + : ownVotes.reduce>>((acc, vote) => { + if (isVoteAnswer(vote) || !vote.option_id) return acc; + acc[vote.option_id] = vote; + return acc; + }, {}); +} + +export function extractPollData( + pollResponse: PollResponse, +): PollData { + return { + allow_answers: pollResponse.allow_answers, + allow_user_suggested_options: pollResponse.allow_user_suggested_options, + description: pollResponse.description, + enforce_unique_vote: pollResponse.enforce_unique_vote, + id: pollResponse.id, + is_closed: pollResponse.is_closed, + max_votes_allowed: pollResponse.max_votes_allowed, + name: pollResponse.name, + options: pollResponse.options, + voting_visibility: pollResponse.voting_visibility, + }; +} + +export function extractPollEnrichedData( + pollResponse: PollResponse, +): Omit, 'own_votes' | 'latest_answers'> { + return { + answers_count: pollResponse.answers_count, + latest_votes_by_option: pollResponse.latest_votes_by_option, + vote_count: pollResponse.vote_count, + vote_counts_by_option: pollResponse.vote_counts_by_option, + }; +} diff --git a/src/poll_manager.ts b/src/poll_manager.ts new file mode 100644 index 000000000..ae770c0ba --- /dev/null +++ b/src/poll_manager.ts @@ -0,0 +1,162 @@ +import type { StreamChat } from './client'; +import type { + CreatePollData, + DefaultGenerics, + ExtendableGenerics, + PollResponse, + PollSort, + QueryPollsFilters, + QueryPollsOptions, +} from './types'; +import { Poll } from './poll'; +import { FormatMessageResponse } from './types'; +import { formatMessage } from './utils'; + +export class PollManager { + private client: StreamChat; + // The pollCache contains only polls that have been created and sent as messages + // (i.e only polls that are coupled with a message, can be voted on and require a + // reactive state). It shall work as a basic look-up table for our SDK to be able + // to quickly consume poll state that will be reactive even without the polls being + // rendered within the UI. + private pollCache = new Map>(); + private unsubscribeFunctions: Set<() => void> = new Set(); + + constructor({ client }: { client: StreamChat }) { + this.client = client; + } + + get data(): Map> { + return this.pollCache; + } + + public fromState = (id: string) => { + return this.pollCache.get(id); + }; + + public registerSubscriptions = () => { + if (this.unsubscribeFunctions.size) { + // Already listening for events and changes + return; + } + + this.unsubscribeFunctions.add(this.subscribeMessageNew()); + this.unsubscribeFunctions.add(this.subscribePollUpdated()); + this.unsubscribeFunctions.add(this.subscribePollClosed()); + this.unsubscribeFunctions.add(this.subscribeVoteCasted()); + this.unsubscribeFunctions.add(this.subscribeVoteChanged()); + this.unsubscribeFunctions.add(this.subscribeVoteRemoved()); + }; + + public unregisterSubscriptions = () => { + this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); + this.unsubscribeFunctions.clear(); + }; + + public createPoll = async (poll: CreatePollData) => { + const { poll: createdPoll } = await this.client.createPoll(poll); + + return new Poll({ client: this.client, poll: createdPoll }); + }; + + public getPoll = async (id: string) => { + const cachedPoll = this.fromState(id); + + // optimistically return the cached poll if it exists and update in the background + if (cachedPoll) { + this.client.getPoll(id).then(({ poll }) => this.setOrOverwriteInCache(poll, true)); + return cachedPoll; + } + // fetch it, write to the cache and return otherwise + const { poll } = await this.client.getPoll(id); + + this.setOrOverwriteInCache(poll); + + return this.fromState(id); + }; + + public queryPolls = async (filter: QueryPollsFilters, sort: PollSort = [], options: QueryPollsOptions = {}) => { + const { polls, next } = await this.client.queryPolls(filter, sort, options); + + const pollInstances = polls.map((poll) => { + this.setOrOverwriteInCache(poll, true); + + return this.fromState(poll.id); + }); + + return { + polls: pollInstances, + next, + }; + }; + + public hydratePollCache = (messages: FormatMessageResponse[], overwriteState?: boolean) => { + for (const message of messages) { + if (!message.poll) { + continue; + } + const pollResponse = message.poll as PollResponse; + this.setOrOverwriteInCache(pollResponse, overwriteState); + } + }; + + private setOrOverwriteInCache = (pollResponse: PollResponse, overwriteState?: boolean) => { + const pollFromCache = this.fromState(pollResponse.id); + if (!pollFromCache) { + const poll = new Poll({ client: this.client, poll: pollResponse }); + this.pollCache.set(poll.id, poll); + } else if (overwriteState) { + pollFromCache.reinitializeState(pollResponse); + } + }; + + private subscribePollUpdated = () => { + return this.client.on('poll.updated', (event) => { + if (event.poll?.id) { + this.fromState(event.poll.id)?.handlePollUpdated(event); + } + }).unsubscribe; + }; + + private subscribePollClosed = () => { + return this.client.on('poll.closed', (event) => { + if (event.poll?.id) { + this.fromState(event.poll.id)?.handlePollClosed(event); + } + }).unsubscribe; + }; + + private subscribeVoteCasted = () => { + return this.client.on('poll.vote_casted', (event) => { + if (event.poll?.id) { + this.fromState(event.poll.id)?.handleVoteCasted(event); + } + }).unsubscribe; + }; + + private subscribeVoteChanged = () => { + return this.client.on('poll.vote_changed', (event) => { + if (event.poll?.id) { + this.fromState(event.poll.id)?.handleVoteChanged(event); + } + }).unsubscribe; + }; + + private subscribeVoteRemoved = () => { + return this.client.on('poll.vote_removed', (event) => { + if (event.poll?.id) { + this.fromState(event.poll.id)?.handleVoteRemoved(event); + } + }).unsubscribe; + }; + + private subscribeMessageNew = () => { + return this.client.on('message.new', (event) => { + const { message } = event; + if (message) { + const formattedMessage = formatMessage(message); + this.hydratePollCache([formattedMessage]); + } + }).unsubscribe; + }; +} diff --git a/src/types.ts b/src/types.ts index c59215442..29e9ea51d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1227,7 +1227,7 @@ export type Event; - poll_vote?: PollVote; + poll_vote?: PollVote | PollAnswer; queriedChannels?: { channels: ChannelAPIResponse[]; isLatestMessageSet?: boolean; @@ -1490,6 +1490,12 @@ export type ChannelFilters; +export type QueryPollsParams = { + filter?: QueryPollsFilters; + options?: QueryPollsOptions; + sort?: PollSort; +}; + export type QueryPollsOptions = Pager; export type VotesFiltersOptions = { @@ -3017,30 +3023,23 @@ export type UpdatePollAPIResponse = StreamChatGenerics['pollType'] & { - answers_count: number; - created_at: string; - created_by: UserResponse | null; - created_by_id: string; - enforce_unique_vote: boolean; - id: string; - latest_answers: PollVote[]; - latest_votes_by_option: Record[]>; - max_votes_allowed: number; - name: string; - options: PollOption[]; - updated_at: string; - vote_count: number; - vote_counts_by_option: Record; - allow_answers?: boolean; - allow_user_suggested_options?: boolean; - channel?: ChannelAPIResponse | null; - cid?: string; - description?: string; - is_closed?: boolean; - own_votes?: PollVote[]; - voting_visibility?: VotingVisibility; -}; +> = StreamChatGenerics['pollType'] & + PollEnrichData & { + created_at: string; + created_by: UserResponse | null; + created_by_id: string; + enforce_unique_vote: boolean; + id: string; + max_votes_allowed: number; + name: string; + options: PollOption[]; + updated_at: string; + allow_answers?: boolean; + allow_user_suggested_options?: boolean; + description?: string; + is_closed?: boolean; + voting_visibility?: VotingVisibility; + }; export type PollOption = { created_at: string; @@ -3057,15 +3056,24 @@ export enum VotingVisibility { public = 'public', } +export type PollEnrichData = { + answers_count: number; + latest_answers: PollAnswer[]; // not updated with WS events, ordered DESC by created_at, seems like updated_at cannot be different from created_at + latest_votes_by_option: Record[]>; // not updated with WS events; always null in anonymous polls + vote_count: number; + vote_counts_by_option: Record; + own_votes?: (PollVote | PollAnswer)[]; // not updated with WS events +}; + export type PollData< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics > = StreamChatGenerics['pollType'] & { + id: string; name: string; allow_answers?: boolean; allow_user_suggested_options?: boolean; description?: string; enforce_unique_vote?: boolean; - id?: string; is_closed?: boolean; max_votes_allowed?: number; options?: PollOptionData[]; @@ -3073,15 +3081,19 @@ export type PollData< voting_visibility?: VotingVisibility; }; +export type CreatePollData = Partial< + PollData +> & + Pick, 'name'>; + export type PartialPollUpdate = { - // id: string; - set?: Partial>; - unset?: Array>; + set?: Partial>; + unset?: Array>; }; export type PollOptionData< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics -> = StreamChatGenerics['pollType'] & { +> = StreamChatGenerics['pollOptionType'] & { text: string; id?: string; position?: number; @@ -3130,21 +3142,33 @@ export type PollOptionResponse< export type PollVote = { created_at: string; id: string; - is_answer: boolean; poll_id: string; - user_id: string; - answer_text?: string; + updated_at: string; option_id?: string; user?: UserResponse; + user_id?: string; +}; + +export type PollAnswer = Exclude< + PollVote, + 'option_id' +> & { + answer_text: string; + is_answer: boolean; // this is absolutely redundant prop as answer_text indicates that a vote is an answer }; export type PollVotesAPIResponse = { - votes: PollVote[]; + votes: (PollVote | PollAnswer)[]; + next?: string; +}; + +export type PollAnswersAPIResponse = { + votes: PollAnswer[]; // todo: should be changes to answers? next?: string; }; export type CastVoteAPIResponse = { - vote: PollVote; + vote: PollVote | PollAnswer; }; export type QueryMessageHistoryFilters = QueryFilters< diff --git a/test/unit/poll.test.js b/test/unit/poll.test.js new file mode 100644 index 000000000..b761dd3aa --- /dev/null +++ b/test/unit/poll.test.js @@ -0,0 +1,702 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { Poll, StreamChat } from '../../src'; + +const pollId = 'WD4SBRJvLoGwB4oAoCQGM'; + +const user1 = { + id: 'admin', + role: 'admin', + created_at: '2022-03-08T09:46:56.840739Z', + updated_at: '2024-09-13T13:53:32.883409Z', + last_active: '2024-10-23T08:14:23.299448386Z', + banned: false, + online: true, + mutes: null, + name: 'Test User', +}; + +const user1Votes = [ + { + poll_id: pollId, + id: '332da4fe-e38c-465c-8f74-e8df69680f13', + option_id: '85610252-7d50-429c-8183-51a7eba46246', + user_id: user1.id, + user: user1, + created_at: '2024-10-22T15:58:27.756166Z', + updated_at: '2024-10-22T15:58:27.756166Z', + }, + { + poll_id: pollId, + id: '5657da00-256e-41fc-a580-b7adabcbfbe1', + option_id: 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', + user_id: user1.id, + user: user1, + created_at: '2024-10-22T15:58:25.886491Z', + updated_at: '2024-10-22T15:58:25.886491Z', + }, +]; + +const user2 = { + id: 'SmithAnne', + role: 'user', + created_at: '2022-01-27T08:28:28.412254Z', + updated_at: '2024-09-26T10:12:23.427141Z', + last_active: '2024-10-23T08:01:43.157632831Z', + banned: false, + online: true, + nickname: 'Ann', + name: 'SmithAnne', + image: 'https://getstream.io/random_png/?name=SmithAnne', +}; + +const user2Votes = [ + { + poll_id: pollId, + id: 'f428f353-3057-4353-b0b5-b33dcdeb1992', + option_id: '7312e983-b042-4596-b5ce-f9e82deb363f', + user_id: user2.id, + user: user2, + created_at: '2024-10-22T16:00:50.2493Z', + updated_at: '2024-10-22T16:00:50.2493Z', + }, + { + poll_id: pollId, + id: '75ba8774-bf17-4edd-8ced-39e7dc6aa7dd', + option_id: '85610252-7d50-429c-8183-51a7eba46246', + user_id: user2.id, + user: user2, + created_at: '2024-10-22T16:00:54.410474Z', + updated_at: '2024-10-22T16:00:54.410474Z', + }, +]; + +const user1Answer = { + poll_id: pollId, + id: 'dbb4506c-c5a8-4ca6-86ec-0c57498916fe', + option_id: '', + is_answer: true, + answer_text: 'comment1', + user_id: user1.id, + user: user1, + created_at: '2024-10-23T13:12:57.944913Z', + updated_at: '2024-10-23T13:12:57.944913Z', +}; + +const user2Answer = { + poll_id: pollId, + id: 'dbb4506c-c5a8-4ca6-86ec-0c57498916xy', + option_id: '', + is_answer: true, + answer_text: 'comment2', + user_id: user2.id, + user: user2, + created_at: '2024-10-23T13:12:57.944913Z', + updated_at: '2024-10-23T13:12:57.944913Z', +}; + +const pollResponse = { + id: pollId, + name: 'XY', + description: '', + voting_visibility: 'public', + enforce_unique_vote: false, + max_votes_allowed: 2, + allow_user_suggested_options: false, + allow_answers: true, + vote_count: 4, + options: [ + { + id: '85610252-7d50-429c-8183-51a7eba46246', + text: 'A', + }, + { + id: '7312e983-b042-4596-b5ce-f9e82deb363f', + text: 'B', + }, + { + id: 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8', + text: 'C', + }, + { + id: 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', + text: 'D', + }, + ], + vote_counts_by_option: { + '7312e983-b042-4596-b5ce-f9e82deb363f': 1, + '85610252-7d50-429c-8183-51a7eba46246': 2, + 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c': 1, + }, + answers_count: 1, + latest_votes_by_option: { + 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c': [user1Votes[1]], + '7312e983-b042-4596-b5ce-f9e82deb363f': [user2Votes[0]], + '85610252-7d50-429c-8183-51a7eba46246': [user1Votes[0], user2Votes[1]], + }, + latest_answers: [user1Answer, user2Answer], + own_votes: [...user1Votes, user1Answer], + created_by_id: user1.id, + created_by: user1, + created_at: '2024-10-22T15:28:20.580523Z', + updated_at: '2024-10-22T15:28:20.580523Z', +}; + +// const client = sinon.createStubInstance(StreamChat); +const client = new StreamChat('apiKey'); +client.user = user1; +client.userID = user1.id; +describe('Poll', () => { + afterEach(() => { + sinon.reset(); + }); + + it('should initialize poll correctly', () => { + const poll = new Poll({ client, poll: pollResponse }); + expect(poll.id).to.equal(pollResponse.id); + Object.entries(poll.data).forEach(([key, val]) => { + if (['id', 'own_votes'].includes(key)) { + expect(poll.data).not.to.have(key); + } else if (key === 'maxVotedOptionIds') { + expect(val).to.eql(['85610252-7d50-429c-8183-51a7eba46246']); + } else if (key === 'ownVotesByOptionId') { + expect(val).to.eql({ + '85610252-7d50-429c-8183-51a7eba46246': user1Votes[0], + 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c': user1Votes[1], + }); + } else if (key === 'ownAnswer') { + expect(val).to.eql(user1Answer); + } else if (key === 'lastActivityAt') { + } else { + expect(val).to.eql(pollResponse[key]); + } + }); + }); + + it('should update poll state when handlePollUpdated is called', () => { + const poll = new Poll({ client, poll: pollResponse }); + const description = 'Description update'; + const updateEvent = { + type: 'poll.updated', + poll: { ...pollResponse, description }, + }; + + poll.handlePollUpdated(updateEvent); + + expect(poll.data.description).to.equal(description); + }); + + it("should not update poll state when handlePollUpdated is called with other poll's event", () => { + const poll = new Poll({ client, poll: { ...pollResponse, id: 'X' } }); + const description = 'Description update'; + const updateEvent = { + type: 'poll.updated', + poll: { ...pollResponse, description }, + }; + + poll.handlePollUpdated(updateEvent); + + expect(poll.data.description).to.equal(pollResponse.description); + }); + + it('should not update poll state when handlePollUpdated is called with other event that poll.updated', () => { + const poll = new Poll({ client, poll: pollResponse }); + const description = 'Description update'; + const updateEvent = { + type: 'poll.closed', + poll: { ...pollResponse, description }, + }; + + poll.handlePollUpdated(updateEvent); + + expect(poll.data.description).to.equal(pollResponse.description); + }); + + it('should close the poll when handlePollClosed is called', () => { + const poll = new Poll({ client, poll: pollResponse }); + expect(poll.data.is_closed).to.be.undefined; + const closeEvent = { + type: 'poll.closed', + poll: { ...pollResponse, is_closed: true }, + }; + + poll.handlePollClosed(closeEvent); + + expect(poll.data.is_closed).to.be.true; + }); + + it("should not close the poll when handlePollClosed is called with other poll's event", () => { + const poll = new Poll({ client, poll: pollResponse }); + expect(poll.data.is_closed).to.be.undefined; + const closeEvent = { + type: 'poll.closed', + poll: { ...pollResponse, id: 'X', is_closed: true }, + }; + + poll.handlePollClosed(closeEvent); + + expect(poll.data.is_closed).to.be.undefined; + }); + + it('should not close the poll when handlePollClosed is called with other event that poll.closed', () => { + const poll = new Poll({ client, poll: pollResponse }); + expect(poll.data.is_closed).to.be.undefined; + const closeEvent = { + type: 'poll.updated', + poll: { ...pollResponse, is_closed: true }, + }; + + poll.handlePollClosed(closeEvent); + + expect(poll.data.is_closed).to.be.undefined; + }); + + it('should add a vote when handleVoteCasted is called', () => { + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const castedVote = { + poll_id: pollId, + id: '332da4fe-e38c-465c-8f74-e8df69680123', + option_id: 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', + user_id: user2.id, + user: user2, + created_at: '2024-10-23T15:58:27.756166Z', + updated_at: '2024-10-23T15:58:27.756166Z', + }; + + const vote_count = originalState.vote_count + 1; + + const vote_counts_by_option = { + ...originalState.vote_counts_by_option, + [castedVote.option_id]: originalState.vote_counts_by_option[castedVote.option_id] + 1, + }; + + const latest_votes_by_option = { + ...originalState.latest_votes_by_option, + [castedVote.option_id]: [...originalState.latest_votes_by_option[castedVote.option_id], castedVote], + }; + + poll.handleVoteCasted({ + type: 'poll.vote_casted', + poll: { ...pollResponse, latest_votes_by_option, vote_count, vote_counts_by_option }, + poll_vote: castedVote, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(originalState.ownVotesByOptionId); + expect(poll.data.ownAnswer).to.eql(originalState.ownAnswer); + expect(poll.data.latest_answers).to.eql(originalState.latest_answers); + expect(poll.data.latest_votes_by_option).to.eql(latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql([...originalState.maxVotedOptionIds, castedVote.option_id]); + }); + + it('should add own vote when handleVoteCasted is called', () => { + client.userID = user1.id; + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const castedVote = { + poll_id: pollId, + id: '332da4fe-e38c-465c-8f74-e8df69680123', + option_id: 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8', + user_id: user1.id, + user: user1, + created_at: '2024-10-23T15:58:27.756166Z', + updated_at: '2024-10-23T15:58:27.756166Z', + }; + + const vote_count = originalState.vote_count + 1; + + const vote_counts_by_option = { + ...originalState.vote_counts_by_option, + [castedVote.option_id]: originalState.vote_counts_by_option[castedVote.option_id] + 1, + }; + + const latest_votes_by_option = { + ...originalState.latest_votes_by_option, + [castedVote.option_id]: [castedVote], + }; + + const ownVotesByOptionId = { + ...originalState.ownVotesByOptionId, + 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8': castedVote, + }; + + poll.handleVoteCasted({ + type: 'poll.vote_casted', + poll: { ...pollResponse, latest_votes_by_option, vote_count, vote_counts_by_option }, + poll_vote: castedVote, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(ownVotesByOptionId); + expect(poll.data.ownAnswer).to.eql(originalState.ownAnswer); + expect(poll.data.latest_answers).to.eql(originalState.latest_answers); + expect(poll.data.latest_votes_by_option).to.eql(latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql(originalState.maxVotedOptionIds); + }); + + it('should add an answer when handleVoteCasted is called', () => { + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const castedVote = { + answer_text: 'XXXX', + poll_id: pollId, + id: '332da4fe-e38c-465c-8f74-e8df69680123', + is_answer: true, + user_id: user2.id, + user: user2, + created_at: '2024-10-23T15:58:27.756166Z', + updated_at: '2024-10-23T15:58:27.756166Z', + }; + + poll.handleVoteCasted({ + type: 'poll.vote_casted', + poll: { ...pollResponse }, + poll_vote: castedVote, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(originalState.ownVotesByOptionId); + expect(poll.data.ownAnswer).to.eql(originalState.ownAnswer); + expect(poll.data.latest_answers).to.eql([castedVote, ...originalState.latest_answers]); + expect(poll.data.latest_votes_by_option).to.eql(originalState.latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql(originalState.maxVotedOptionIds); + }); + + it('should add own answer when handleVoteCasted is called', () => { + client.userID = user1.id; + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const castedVote = { + answer_text: 'XXXX', + poll_id: pollId, + id: '332da4fe-e38c-465c-8f74-e8df69680123', + is_answer: true, + user_id: user1.id, + user: user1, + created_at: '2024-10-23T15:58:27.756166Z', + updated_at: '2024-10-23T15:58:27.756166Z', + }; + + poll.handleVoteCasted({ + type: 'poll.vote_casted', + poll: { ...pollResponse }, + poll_vote: castedVote, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(originalState.ownVotesByOptionId); + expect(poll.data.ownAnswer).to.eql(castedVote); + expect(poll.data.latest_answers).to.eql([castedVote, ...originalState.latest_answers]); + expect(poll.data.latest_votes_by_option).to.eql(originalState.latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql(originalState.maxVotedOptionIds); + }); + + it('should change a vote when handleVoteChanged is called', () => { + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const changedToOptionId = 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c'; + const castedVote = { + poll_id: pollId, + id: '332da4fe-e38c-465c-8f74-e8df69680123', + option_id: changedToOptionId, + user_id: user2.id, + user: user2, + created_at: '2024-10-23T15:58:27.756166Z', + }; + + const vote_counts_by_option = { + ...originalState.vote_counts_by_option, + [changedToOptionId]: (originalState.vote_counts_by_option[changedToOptionId] ?? 0) + 1, + }; + + const latest_votes_by_option = { + ...originalState.latest_votes_by_option, + [changedToOptionId]: [...originalState.latest_votes_by_option[changedToOptionId], castedVote], + }; + + poll.handleVoteChanged({ + type: 'poll.vote_changed', + poll: { ...pollResponse, latest_votes_by_option, vote_counts_by_option }, + poll_vote: castedVote, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(originalState.ownVotesByOptionId); + expect(poll.data.ownAnswer).to.eql(originalState.ownAnswer); + expect(poll.data.latest_answers).to.eql(originalState.latest_answers); + expect(poll.data.latest_votes_by_option).to.eql(latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql([...originalState.maxVotedOptionIds, changedToOptionId]); + }); + + it('should change own vote when handleVoteChanged is called', () => { + client.userID = user1.id; + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const changedToOptionId = 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c'; + const castedVote = { + poll_id: pollId, + id: '332da4fe-e38c-465c-8f74-e8df69680123', + option_id: changedToOptionId, + user_id: user1.id, + user: user1, + created_at: '2024-10-23T15:58:27.756166Z', + }; + + const vote_counts_by_option = { + ...originalState.vote_counts_by_option, + [changedToOptionId]: (originalState.vote_counts_by_option[changedToOptionId] ?? 0) + 1, + }; + + const latest_votes_by_option = { + ...originalState.latest_votes_by_option, + [changedToOptionId]: [...originalState.latest_votes_by_option[changedToOptionId], castedVote], + }; + + poll.handleVoteChanged({ + type: 'poll.vote_changed', + poll: { ...pollResponse, latest_votes_by_option, vote_counts_by_option }, + poll_vote: castedVote, + }); + + expect(poll.data.ownVotesByOptionId).to.eql({ + ...originalState.ownVotesByOptionId, + [changedToOptionId]: castedVote, + }); + expect(poll.data.ownAnswer).to.eql(originalState.ownAnswer); + expect(poll.data.latest_answers).to.eql(originalState.latest_answers); + expect(poll.data.latest_votes_by_option).to.eql(latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql([...originalState.maxVotedOptionIds, changedToOptionId]); + }); + + it('should change an answer when handleVoteChanged is called', () => { + client.userID = user2.id; + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const changedAnswer = { + ...user1Answer, + answer_text: 'changed', + }; + + poll.handleVoteChanged({ + type: 'poll.vote_changed', + poll: { ...pollResponse }, + poll_vote: changedAnswer, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(originalState.ownVotesByOptionId); + expect(poll.data.ownAnswer).to.eql(originalState.ownAnswer); + expect(poll.data.latest_answers).to.eql([changedAnswer, ...originalState.latest_answers]); + expect(poll.data.latest_votes_by_option).to.eql(originalState.latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql(originalState.maxVotedOptionIds); + }); + + it('should change own answer when handleVoteChanged is called', () => { + client.userID = user1.id; + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const changedAnswer = { + ...user1Answer, + answer_text: 'changed', + }; + + poll.handleVoteChanged({ + type: 'poll.vote_changed', + poll: { ...pollResponse }, + poll_vote: changedAnswer, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(originalState.ownVotesByOptionId); + expect(poll.data.ownAnswer).to.eql(changedAnswer); + expect(poll.data.latest_answers).to.eql([ + changedAnswer, + ...originalState.latest_answers.filter((a) => a.id !== changedAnswer.id), + ]); + expect(poll.data.latest_votes_by_option).to.eql(originalState.latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql(originalState.maxVotedOptionIds); + }); + + it('should remove a vote when handleVoteRemoved is called', () => { + client.userID = user1.id; + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const vote_counts_by_option = { + ...originalState.vote_counts_by_option, + [user2Votes[1].option_id]: originalState.vote_counts_by_option[user2Votes[1].option_id] - 1, + }; + + const latest_votes_by_option = { + ...originalState.latest_votes_by_option, + [user2Votes[1].option_id]: originalState.latest_votes_by_option[user2Votes[1].option_id].filter( + (v) => v.option_id !== user2Votes[1].option_id, + ), + }; + + poll.handleVoteRemoved({ + type: 'poll.vote_removed', + poll: { + ...pollResponse, + latest_votes_by_option, + vote_count: originalState.vote_count - 1, + vote_counts_by_option, + }, + poll_vote: { ...user2Votes[1], user_id: user2Votes[1].user.id }, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(originalState.ownVotesByOptionId); + expect(poll.data.ownAnswer).to.eql(originalState.ownAnswer); + expect(poll.data.latest_answers).to.eql(originalState.latest_answers); + expect(poll.data.latest_votes_by_option).to.eql(latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql([ + '7312e983-b042-4596-b5ce-f9e82deb363f', + '85610252-7d50-429c-8183-51a7eba46246', + 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', + ]); + }); + + it('should remove own vote when handleVoteRemoved is called', () => { + client.userID = user1.id; + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const removedVote = user1Votes[0]; + const optionId = user1Votes[0].option_id; + const vote_counts_by_option = { + ...originalState.vote_counts_by_option, + [optionId]: originalState.vote_counts_by_option[optionId] - 1, + }; + + const latest_votes_by_option = { + ...originalState.latest_votes_by_option, + [optionId]: originalState.latest_votes_by_option[optionId].filter( + (v) => v.option_id !== user1Votes[1].option_id, + ), + }; + + poll.handleVoteRemoved({ + type: 'poll.vote_removed', + poll: { + ...pollResponse, + latest_votes_by_option, + vote_count: originalState.vote_count - 1, + vote_counts_by_option, + }, + poll_vote: { ...removedVote, user_id: client.userID }, + }); + + expect(poll.data.ownVotesByOptionId).to.eql({ 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c': user1Votes[1] }); + expect(poll.data.ownAnswer).to.eql(originalState.ownAnswer); + expect(poll.data.latest_answers).to.eql(originalState.latest_answers); + expect(poll.data.latest_votes_by_option).to.eql(latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql([ + '7312e983-b042-4596-b5ce-f9e82deb363f', + '85610252-7d50-429c-8183-51a7eba46246', + 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', + ]); + }); + + it('should remove an answer when handleVoteRemoved is called', () => { + client.userID = user1.id; + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const removedAnswer = user2Answer; + + poll.handleVoteRemoved({ + type: 'poll.vote_removed', + poll: { ...pollResponse }, + poll_vote: { ...removedAnswer, user_id: user2Answer.user_id }, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(originalState.ownVotesByOptionId); + expect(poll.data.ownAnswer).to.eql(originalState.ownAnswer); + expect(poll.data.latest_answers).to.eql(originalState.latest_answers.filter((a) => a.id !== removedAnswer.id)); + expect(poll.data.latest_votes_by_option).to.eql(originalState.latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql(originalState.maxVotedOptionIds); + }); + + it('should remove own answer when handleVoteRemoved is called', () => { + client.userID = user1.id; + const poll = new Poll({ client, poll: pollResponse }); + const originalState = poll.data; + const removedAnswer = user1Answer; + + poll.handleVoteRemoved({ + type: 'poll.vote_removed', + poll: { ...pollResponse }, + poll_vote: { ...removedAnswer, user_id: client.userID }, + }); + + expect(poll.data.ownVotesByOptionId).to.eql(originalState.ownVotesByOptionId); + expect(poll.data.ownAnswer).to.be.undefined; + expect(poll.data.latest_answers).to.eql(originalState.latest_answers.filter((a) => a.id !== removedAnswer.id)); + expect(poll.data.latest_votes_by_option).to.eql(originalState.latest_votes_by_option); + expect(poll.data.maxVotedOptionIds).to.eql(originalState.maxVotedOptionIds); + }); + + it('should fetch poll data when query is called', async () => { + const mockPollResponse = { + name: 'Test question', + options: [{ id: 'option1', text: 'Option 1' }], + own_votes: [], + vote_counts_by_option: {}, + latest_answers: [], + }; + + const poll = new Poll({ client, poll: pollResponse }); + const getPollStub = sinon.stub(client, 'getPoll'); + getPollStub.resolves({ poll: mockPollResponse }); + const originalState = poll.data; + await poll.query(pollResponse.id); + + expect(getPollStub.calledWith(pollResponse.id)).to.be.true; + const { lastActivityAt: __, ...currentPollState } = poll.data; + const { lastActivityAt: _, ...expectedPollState } = { ...originalState, ...mockPollResponse }; + expect(currentPollState).to.eql(expectedPollState); + getPollStub.restore(); + }); + + it('should remove oldest vote before casting a new one if reached max votes allowed', async () => { + const poll = new Poll({ client, poll: { ...pollResponse, max_votes_allowed: user2Votes.length } }); + const option_id = 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8'; + const messageId = 'XXX'; + const removePollVoteStub = sinon.stub(client, 'removePollVote'); + const castPollVoteStub = sinon.stub(client, 'castPollVote'); + removePollVoteStub.resolves('removed'); + castPollVoteStub.resolves({ vote: { id: 'vote1', option_id, user_id: 'user1' } }); + + await poll.castVote(option_id, messageId); + + expect(removePollVoteStub.calledWith(messageId, pollResponse.id, user1Votes[1].id)).to.be.true; + expect(castPollVoteStub.calledWith(messageId, pollResponse.id, { option_id })).to.be.true; + removePollVoteStub.restore(); + castPollVoteStub.restore(); + }); + + it('should not remove oldest vote before casting a new one if not reached max votes allowed', async () => { + const poll = new Poll({ client, poll: { ...pollResponse, max_votes_allowed: user2Votes.length + 1 } }); + const option_id = 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8'; + const messageId = 'XXX'; + const removePollVoteStub = sinon.stub(client, 'removePollVote'); + const castPollVoteStub = sinon.stub(client, 'castPollVote'); + removePollVoteStub.resolves('removed'); + castPollVoteStub.resolves({ vote: { id: 'vote1', option_id, user_id: 'user1' } }); + + await poll.castVote(option_id, messageId); + + expect(removePollVoteStub.called).to.be.false; + expect(castPollVoteStub.calledWith(messageId, pollResponse.id, { option_id })).to.be.true; + removePollVoteStub.restore(); + castPollVoteStub.restore(); + }); + + it('should not remove oldest vote before casting a new one if max_votes_allowed is not defined', async () => { + const poll = new Poll({ client, poll: { ...pollResponse, max_votes_allowed: undefined } }); + const option_id = 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8'; + const messageId = 'XXX'; + const removePollVoteStub = sinon.stub(client, 'removePollVote'); + const castPollVoteStub = sinon.stub(client, 'castPollVote'); + removePollVoteStub.resolves('removed'); + castPollVoteStub.resolves({ vote: { id: 'vote1', option_id, user_id: 'user1' } }); + + await poll.castVote(option_id, messageId); + + expect(removePollVoteStub.called).to.be.false; + expect(castPollVoteStub.calledWith(messageId, pollResponse.id, { option_id })).to.be.true; + removePollVoteStub.restore(); + castPollVoteStub.restore(); + }); +}); diff --git a/test/unit/poll_manager.test.ts b/test/unit/poll_manager.test.ts new file mode 100644 index 000000000..08ab3ed8a --- /dev/null +++ b/test/unit/poll_manager.test.ts @@ -0,0 +1,480 @@ +import { expect } from 'chai'; + +import { generateMsg } from './test-utils/generateMessage'; +import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; + +import sinon from 'sinon'; +import { + EventTypes, + FormatMessageResponse, + MessageResponse, + Poll, + PollManager, + PollResponse, + StreamChat, +} from '../../src'; + +const TEST_USER_ID = 'observer'; + +let client: StreamChat; +let pollManager: PollManager; + +// TODO: probably extract this elsewhere +const generatePollMessage = ( + pollId: string, + extraData: Partial = {}, + messageOverrides: Partial = {}, +) => { + const user1 = { + id: 'admin', + role: 'admin', + created_at: '2022-03-08T09:46:56.840739Z', + updated_at: '2024-09-13T13:53:32.883409Z', + last_active: '2024-10-23T08:14:23.299448386Z', + banned: false, + online: true, + mutes: null, + name: 'Test User', + }; + + const user1Votes = [ + { + poll_id: pollId, + id: '332da4fe-e38c-465c-8f74-e8df69680f13', + option_id: '85610252-7d50-429c-8183-51a7eba46246', + user_id: user1.id, + user: user1, + created_at: '2024-10-22T15:58:27.756166Z', + updated_at: '2024-10-22T15:58:27.756166Z', + }, + { + poll_id: pollId, + id: '5657da00-256e-41fc-a580-b7adabcbfbe1', + option_id: 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', + user_id: user1.id, + user: user1, + created_at: '2024-10-22T15:58:25.886491Z', + updated_at: '2024-10-22T15:58:25.886491Z', + }, + ]; + + const user2 = { + id: 'SmithAnne', + role: 'user', + created_at: '2022-01-27T08:28:28.412254Z', + updated_at: '2024-09-26T10:12:23.427141Z', + last_active: '2024-10-23T08:01:43.157632831Z', + banned: false, + online: true, + nickname: 'Ann', + name: 'SmithAnne', + image: 'https://getstream.io/random_png/?name=SmithAnne', + }; + + const user2Votes = [ + { + poll_id: pollId, + id: 'f428f353-3057-4353-b0b5-b33dcdeb1992', + option_id: '7312e983-b042-4596-b5ce-f9e82deb363f', + user_id: user2.id, + user: user2, + created_at: '2024-10-22T16:00:50.2493Z', + updated_at: '2024-10-22T16:00:50.2493Z', + }, + { + poll_id: pollId, + id: '75ba8774-bf17-4edd-8ced-39e7dc6aa7dd', + option_id: 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8', + user_id: user2.id, + user: user2, + created_at: '2024-10-22T16:00:54.410474Z', + updated_at: '2024-10-22T16:00:54.410474Z', + }, + ]; + + const user1Answer = { + poll_id: pollId, + id: 'dbb4506c-c5a8-4ca6-86ec-0c57498916fe', + option_id: '', + is_answer: true, + answer_text: 'comment1', + user_id: user1.id, + user: user1, + created_at: '2024-10-23T13:12:57.944913Z', + updated_at: '2024-10-23T13:12:57.944913Z', + }; + + const pollResponse = { + id: pollId, + name: 'XY', + description: '', + voting_visibility: 'public', + enforce_unique_vote: false, + max_votes_allowed: 2, + allow_user_suggested_options: false, + allow_answers: true, + vote_count: 4, + options: [ + { + id: '85610252-7d50-429c-8183-51a7eba46246', + text: 'A', + }, + { + id: '7312e983-b042-4596-b5ce-f9e82deb363f', + text: 'B', + }, + { + id: 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8', + text: 'C', + }, + { + id: 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c', + text: 'D', + }, + ], + vote_counts_by_option: { + '7312e983-b042-4596-b5ce-f9e82deb363f': 1, + '85610252-7d50-429c-8183-51a7eba46246': 2, + 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c': 1, + }, + answers_count: 1, + latest_votes_by_option: { + 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c': [user1Votes[0]], + '7312e983-b042-4596-b5ce-f9e82deb363f': [user2Votes[0]], + '85610252-7d50-429c-8183-51a7eba46246': [user1Votes[1], user2Votes[1]], + }, + latest_answers: [user1Answer], + own_votes: [...user1Votes, user1Answer], + created_by_id: user1.id, + created_by: user1, + created_at: '2024-10-22T15:28:20.580523Z', + updated_at: '2024-10-22T15:28:20.580523Z', + ...extraData, + }; + + return generateMsg({ poll_id: pollId, poll: pollResponse, ...messageOverrides }); +}; +const generateRandomMessagesWithPolls = (size: number = 5, prefix: string = '') => { + const messages = []; + const pollMessages = []; + for (let pi = 0; pi < size; pi++) { + let message = generateMsg(); + if (Math.random() >= 0.5) { + message = generatePollMessage(`poll_${prefix}${pi}`); + pollMessages.push(message); + } + messages.push(message); + } + + return { messages, pollMessages }; +}; + +describe('PollManager', () => { + beforeEach(() => { + client = new StreamChat('apiKey'); + client._setUser({ id: TEST_USER_ID }); + pollManager = new PollManager({ client }); + pollManager.registerSubscriptions(); + }); + + afterEach(() => { + pollManager.unregisterSubscriptions(); + sinon.restore(); + }); + + describe('Initialization and pollCache hydration', () => { + it('initializes properly', () => { + expect(pollManager.data).to.be.empty; + expect(client.polls.data).to.be.empty; + expect(client.polls.data).not.to.be.null; + }); + + it('populates pollCache on client.hydrateActiveChannels', async () => { + const mockedChannelsQueryResponse = []; + + let pollMessages: MessageResponse[] = []; + for (let ci = 0; ci < 2; ci++) { + const { messages, pollMessages: onlyPollMessages } = generateRandomMessagesWithPolls(5, `_${ci}`); + pollMessages = pollMessages.concat(onlyPollMessages); + mockedChannelsQueryResponse.push({ + ...mockChannelQueryResponse, + messages, + }); + } + + client.hydrateActiveChannels(mockedChannelsQueryResponse); + + expect(client.polls.data.size).to.equal(pollMessages.length); + // Map.prototype.keys() preserves the insertion order so we can do this + expect(Array.from(client.polls.data.keys())).to.deep.equal(pollMessages.map((m) => m.poll_id)); + }); + + it('populates pollCache when the message.new event is fired', () => { + client.dispatchEvent({ + type: 'message.new', + message: generateMsg({ user: { id: 'bob' } }), + user: { id: 'bob' }, + }); + + const pollMessage = generatePollMessage('poll_from_event', {}, { user: { id: 'bob' } }); + + client.dispatchEvent({ + type: 'message.new', + message: pollMessage, + user: { id: 'bob' }, + }); + + expect(pollManager.data.size).to.equal(1); + expect(pollManager.fromState('poll_from_event')?.id).to.equal('poll_from_event'); + + client.dispatchEvent({ + type: 'message.new', + message: pollMessage, + user: { id: 'bob' }, + }); + + // do not duplicate if it's been sent again, for example in a different channel; + // the state should be shared. + expect(pollManager.data.size).to.equal(1); + }); + + it('correctly hydrates the poll cache', () => { + const { messages, pollMessages } = generateRandomMessagesWithPolls(5); + + pollManager.hydratePollCache(messages); + + expect(pollManager.data.size).to.equal(pollMessages.length); + expect(Array.from(pollManager.data.keys())).to.deep.equal(pollMessages.map((m) => m.poll_id)); + }); + + it('correctly upserts duplicate polls within the cache', () => { + const { messages, pollMessages } = generateRandomMessagesWithPolls(5); + const duplicateId = 'poll_duplicate'; + let duplicatePollMessage = generatePollMessage(duplicateId); + + pollManager.hydratePollCache([...messages, duplicatePollMessage]); + + const finalLength = pollMessages.length + 1; + + // normal initialization + expect(pollManager.data.size).to.equal(finalLength); + expect(Array.from(pollManager.data.keys())).to.deep.equal( + [...pollMessages, duplicatePollMessage].map((m) => m.poll_id), + ); + expect(pollManager.fromState(duplicateId)?.data.name).to.equal(duplicatePollMessage.poll.name); + + // many duplicate messages + const duplicates = []; + for (let di = 0; di < 5; di++) { + const newDuplicateMessage = generatePollMessage(duplicateId, { name: `d1_${di}` }); + duplicates.push(newDuplicateMessage); + } + + // without overwriteState + pollManager.hydratePollCache(duplicates); + + expect(pollManager.data.size).to.equal(finalLength); + expect(Array.from(pollManager.data.keys())).to.deep.equal( + [...pollMessages, duplicatePollMessage].map((m) => m.poll_id), + ); + expect(pollManager.fromState(duplicateId)?.data.name).to.equal('XY'); + + // with overwriteState + pollManager.hydratePollCache(duplicates, true); + + expect(pollManager.data.size).to.equal(finalLength); + expect(Array.from(pollManager.data.keys())).to.deep.equal( + [...pollMessages, duplicatePollMessage].map((m) => m.poll_id), + ); + expect(pollManager.fromState(duplicateId)?.data.name).to.equal('d1_4'); + + // many hydrate invocations + for (let di = 0; di < 5; di++) { + const newDuplicateMessage = generatePollMessage(duplicateId, { name: `d2_${di}` }); + pollManager.hydratePollCache([newDuplicateMessage], true); + } + + expect(pollManager.data.size).to.equal(finalLength); + expect(Array.from(pollManager.data.keys())).to.deep.equal( + [...pollMessages, duplicatePollMessage].map((m) => m.poll_id), + ); + expect(pollManager.fromState(duplicateId)?.data.name).to.equal('d2_4'); + }); + }); + describe('Event handling', () => { + const pollId1 = 'poll_1'; + const pollId2 = 'poll_2'; + let pollMessage1: FormatMessageResponse; + let pollMessage2: FormatMessageResponse; + + beforeEach(() => { + pollMessage1 = generatePollMessage(pollId1); + pollMessage2 = generatePollMessage(pollId2); + pollManager.hydratePollCache([pollMessage1, pollMessage2]); + }); + + it('should not register subscription handlers twice', () => { + pollManager.registerSubscriptions(); + + const pollClosedStub = sinon.stub(pollManager.fromState(pollId1) as Poll, 'handlePollClosed'); + + client.dispatchEvent({ + type: 'poll.closed', + poll: pollMessage1.poll as PollResponse, + }); + + expect(pollClosedStub.calledOnce).to.be.true; + }); + + it('should not call subscription handlers if unregisterSubscriptions has been called', () => { + pollManager.unregisterSubscriptions(); + + const voteCastedStub = sinon.stub(pollManager.fromState(pollId1) as Poll, 'handleVoteCasted'); + const pollClosedStub = sinon.stub(pollManager.fromState(pollId1) as Poll, 'handlePollClosed'); + + const poll = pollMessage1.poll as PollResponse; + + client.dispatchEvent({ + type: 'poll.vote_casted', + poll, + }); + + client.dispatchEvent({ + type: 'poll.closed', + poll, + }); + + expect(voteCastedStub.calledOnce).to.be.false; + expect(pollClosedStub.calledOnce).to.be.false; + }); + + it('should update the correct poll within the cache on poll.updated', () => { + const updatedTitle = 'Updated title'; + const spy1 = sinon.spy(pollManager.fromState(pollId1) as Poll, 'handlePollUpdated'); + const spy2 = sinon.spy(pollManager.fromState(pollId2) as Poll, 'handlePollUpdated'); + + const updatedPoll = { ...pollMessage1.poll, name: updatedTitle } as PollResponse; + + client.dispatchEvent({ + type: 'poll.updated', + poll: updatedPoll, + }); + + expect(spy1.calledOnce).to.be.true; + expect(spy1.getCall(0).args[0].type).to.equal('poll.updated'); + expect(spy1.getCall(0).args[0].poll).to.equal(updatedPoll); + expect(spy2.calledOnce).to.be.false; + }); + + const eventHandlerPairs = [ + ['poll.closed', 'handlePollClosed'], + ['poll.vote_casted', 'handleVoteCasted'], + ['poll.vote_changed', 'handleVoteChanged'], + ['poll.vote_removed', 'handleVoteRemoved'], + ]; + + eventHandlerPairs.map(([eventType, handlerName]) => { + it(`should invoke poll.${handlerName} within the cache on ${eventType}`, () => { + const stub1 = sinon.stub(pollManager.fromState(pollId1) as Poll, handlerName as keyof Poll); + const stub2 = sinon.stub(pollManager.fromState(pollId2) as Poll, handlerName as keyof Poll); + + const updatedPoll = pollMessage1.poll as PollResponse; + + client.dispatchEvent({ + type: eventType as EventTypes, + poll: updatedPoll, + }); + + expect(stub1.calledOnce).to.be.true; + expect(stub1.getCall(0).args[0].type).to.equal(eventType); + expect(stub1.getCall(0).args[0].poll).to.equal(updatedPoll); + expect(stub2.calledOnce).to.be.false; + }); + }); + }); + describe('API', () => { + const pollId1 = 'poll_1'; + const pollId2 = 'poll_2'; + let stubbedQueryPolls: sinon.SinonStub, ReturnType>; + let stubbedGetPoll: sinon.SinonStub, ReturnType>; + const pollMessage1: PollResponse = generatePollMessage(pollId1); + const pollMessage2: PollResponse = generatePollMessage(pollId2); + beforeEach(() => { + stubbedQueryPolls = sinon + .stub(client, 'queryPolls') + .resolves({ polls: [pollMessage1.poll as PollResponse, pollMessage2.poll as PollResponse], duration: '10' }); + stubbedGetPoll = sinon + .stub(client, 'getPoll') + .resolves({ poll: pollMessage1.poll as PollResponse, duration: '10' }); + }); + it('should return a Poll instance on queryPolls', async () => { + const { polls } = await pollManager.queryPolls({}, {}, {}); + + expect(polls).to.have.lengthOf(2); + polls.forEach((poll) => { + expect(poll).to.be.instanceof(Poll); + }); + expect(stubbedQueryPolls.calledOnce).to.be.true; + }); + it('should properly populate the pollCache on queryPolls', async () => { + const { polls } = await pollManager.queryPolls({}, {}, {}); + + expect(pollManager.data.size).to.equal(2); + expect(pollManager.fromState(pollId1)).to.not.be.undefined; + expect(pollManager.fromState(pollId2)).to.not.be.undefined; + // each poll should keep the same reference + polls.forEach((poll) => { + if (poll?.id) { + expect(pollManager.fromState(poll?.id)).to.equal(poll); + } + }); + }); + it('should overwrite the state if polls from queryPolls are already present in the cache', async () => { + const duplicatePollMessage = generatePollMessage(pollId1, { title: 'SHOULD CHANGE' }); + pollManager.hydratePollCache([duplicatePollMessage]); + + const previousPollFromCache = pollManager.fromState(pollId1); + + const { polls } = await pollManager.queryPolls({}, {}, {}); + + const pollFromQuery = polls[0]; + + expect(pollManager.data.size).to.equal(2); + expect(pollManager.fromState(pollId1)).to.not.be.undefined; + expect(pollManager.fromState(pollId1)?.data.name).to.equal('XY'); + expect(pollManager.fromState(pollId2)).to.not.be.undefined; + // maintain referential integrity + expect(pollManager.fromState(pollId1)).to.equal(previousPollFromCache); + expect(pollManager.fromState(pollId1)).to.equal(pollFromQuery); + }); + it('should return a Poll instance on getPoll', async () => { + const poll = await pollManager.getPoll(pollId1); + + expect(poll).to.be.instanceof(Poll); + expect(stubbedGetPoll.calledOnce).to.be.true; + }); + it('should properly populate the pollCache on getPoll', async () => { + const poll = await pollManager.getPoll(pollId1); + + expect(pollManager.data.size).to.equal(1); + expect(pollManager.fromState(pollId1)).to.not.be.undefined; + // should have the same reference + expect(pollManager.fromState(pollId1)).to.equal(poll); + }); + it('should overwrite the state if the poll returned from getPoll is present in the cache', async () => { + const duplicatePollMessage = generatePollMessage(pollId1, { title: 'SHOULD CHANGE' }); + pollManager.hydratePollCache([duplicatePollMessage]); + + const previousPollFromCache = pollManager.fromState(pollId1); + + const poll = await pollManager.getPoll(pollId1); + + expect(pollManager.data.size).to.equal(1); + expect(pollManager.fromState(pollId1)).to.not.be.undefined; + expect(pollManager.fromState(pollId1)?.data.name).to.equal('XY'); + // maintain referential integrity + expect(pollManager.fromState(pollId1)).to.equal(previousPollFromCache); + expect(pollManager.fromState(pollId1)).to.equal(poll); + }); + }); +});