* Add polls Fix #1629 * Add tests * Fixes * Change API for creating polls * Use name instead of content for votes * Remove poll validation for remote polls * Add polls to public pages * When updating the poll, update options just in case they were changed * Fix public pages showing both poll and other mediapull/4/head
@ -0,0 +1,29 @@ | |||
# frozen_string_literal: true | |||
class Api::V1::Polls::VotesController < Api::BaseController | |||
include Authorization | |||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' } | |||
before_action :require_user! | |||
before_action :set_poll | |||
respond_to :json | |||
def create | |||
VoteService.new.call(current_account, @poll, vote_params[:choices]) | |||
render json: @poll, serializer: REST::PollSerializer | |||
end | |||
private | |||
def set_poll | |||
@poll = Poll.attached.find(params[:poll_id]) | |||
authorize @poll.status, :show? | |||
rescue Mastodon::NotPermittedError | |||
raise ActiveRecord::RecordNotFound | |||
end | |||
def vote_params | |||
params.permit(choices: []) | |||
end | |||
end |
@ -0,0 +1,13 @@ | |||
# frozen_string_literal: true | |||
class Api::V1::PollsController < Api::BaseController | |||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, only: :show | |||
respond_to :json | |||
def show | |||
@poll = Poll.attached.find(params[:id]) | |||
ActivityPub::FetchRemotePollService.new.call(@poll, current_account) if user_signed_in? && @poll.possibly_stale? | |||
render json: @poll, serializer: REST::PollSerializer, include_results: true | |||
end | |||
end |
@ -0,0 +1,53 @@ | |||
import api from '../api'; | |||
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; | |||
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; | |||
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; | |||
export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; | |||
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; | |||
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; | |||
export const vote = (pollId, choices) => (dispatch, getState) => { | |||
dispatch(voteRequest()); | |||
api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) | |||
.then(({ data }) => dispatch(voteSuccess(data))) | |||
.catch(err => dispatch(voteFail(err))); | |||
}; | |||
export const fetchPoll = pollId => (dispatch, getState) => { | |||
dispatch(fetchPollRequest()); | |||
api(getState).get(`/api/v1/polls/${pollId}`) | |||
.then(({ data }) => dispatch(fetchPollSuccess(data))) | |||
.catch(err => dispatch(fetchPollFail(err))); | |||
}; | |||
export const voteRequest = () => ({ | |||
type: POLL_VOTE_REQUEST, | |||
}); | |||
export const voteSuccess = poll => ({ | |||
type: POLL_VOTE_SUCCESS, | |||
poll, | |||
}); | |||
export const voteFail = error => ({ | |||
type: POLL_VOTE_FAIL, | |||
error, | |||
}); | |||
export const fetchPollRequest = () => ({ | |||
type: POLL_FETCH_REQUEST, | |||
}); | |||
export const fetchPollSuccess = poll => ({ | |||
type: POLL_FETCH_SUCCESS, | |||
poll, | |||
}); | |||
export const fetchPollFail = error => ({ | |||
type: POLL_FETCH_FAIL, | |||
error, | |||
}); |
@ -0,0 +1,144 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import classNames from 'classnames'; | |||
import { vote, fetchPoll } from 'mastodon/actions/polls'; | |||
import Motion from 'mastodon/features/ui/util/optional_motion'; | |||
import spring from 'react-motion/lib/spring'; | |||
const messages = defineMessages({ | |||
moments: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, | |||
seconds: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, | |||
minutes: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, | |||
hours: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' }, | |||
days: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' }, | |||
}); | |||
const SECOND = 1000; | |||
const MINUTE = 1000 * 60; | |||
const HOUR = 1000 * 60 * 60; | |||
const DAY = 1000 * 60 * 60 * 24; | |||
const timeRemainingString = (intl, date, now) => { | |||
const delta = date.getTime() - now; | |||
let relativeTime; | |||
if (delta < 10 * SECOND) { | |||
relativeTime = intl.formatMessage(messages.moments); | |||
} else if (delta < MINUTE) { | |||
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); | |||
} else if (delta < HOUR) { | |||
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); | |||
} else if (delta < DAY) { | |||
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); | |||
} else { | |||
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); | |||
} | |||
return relativeTime; | |||
}; | |||
export default @injectIntl | |||
class Poll extends ImmutablePureComponent { | |||
static propTypes = { | |||
poll: ImmutablePropTypes.map.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
dispatch: PropTypes.func, | |||
disabled: PropTypes.bool, | |||
}; | |||
state = { | |||
selected: {}, | |||
}; | |||
handleOptionChange = e => { | |||
const { target: { value } } = e; | |||
if (this.props.poll.get('multiple')) { | |||
const tmp = { ...this.state.selected }; | |||
tmp[value] = true; | |||
this.setState({ selected: tmp }); | |||
} else { | |||
const tmp = {}; | |||
tmp[value] = true; | |||
this.setState({ selected: tmp }); | |||
} | |||
}; | |||
handleVote = () => { | |||
if (this.props.disabled) { | |||
return; | |||
} | |||
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected))); | |||
}; | |||
handleRefresh = () => { | |||
if (this.props.disabled) { | |||
return; | |||
} | |||
this.props.dispatch(fetchPoll(this.props.poll.get('id'))); | |||
}; | |||
renderOption (option, optionIndex) { | |||
const { poll } = this.props; | |||
const percent = (option.get('votes_count') / poll.get('votes_count')) * 100; | |||
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count')); | |||
const active = !!this.state.selected[`${optionIndex}`]; | |||
const showResults = poll.get('voted') || poll.get('expired'); | |||
return ( | |||
<li key={option.get('title')}> | |||
{showResults && ( | |||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> | |||
{({ width }) => | |||
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> | |||
} | |||
</Motion> | |||
)} | |||
<label className={classNames('poll__text', { selectable: !showResults })}> | |||
<input | |||
name='vote-options' | |||
type={poll.get('multiple') ? 'checkbox' : 'radio'} | |||
value={optionIndex} | |||
checked={active} | |||
onChange={this.handleOptionChange} | |||
/> | |||
{!showResults && <span className={classNames('poll__input', { active })} />} | |||
{showResults && <span className='poll__number'>{Math.floor(percent)}%</span>} | |||
{option.get('title')} | |||
</label> | |||
</li> | |||
); | |||
} | |||
render () { | |||
const { poll, intl } = this.props; | |||
const timeRemaining = timeRemainingString(intl, new Date(poll.get('expires_at')), intl.now()); | |||
const showResults = poll.get('voted') || poll.get('expired'); | |||
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); | |||
return ( | |||
<div className='poll'> | |||
<ul> | |||
{poll.get('options').map((option, i) => this.renderOption(option, i))} | |||
</ul> | |||
<div className='poll__footer'> | |||
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} | |||
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>} | |||
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} /> · {timeRemaining} | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,8 @@ | |||
import { connect } from 'react-redux'; | |||
import Poll from 'mastodon/components/poll'; | |||
const mapStateToProps = (state, { pollId }) => ({ | |||
poll: state.getIn(['polls', pollId]), | |||
}); | |||
export default connect(mapStateToProps)(Poll); |
@ -0,0 +1,19 @@ | |||
import { POLL_VOTE_SUCCESS, POLL_FETCH_SUCCESS } from 'mastodon/actions/polls'; | |||
import { POLLS_IMPORT } from 'mastodon/actions/importer'; | |||
import { Map as ImmutableMap, fromJS } from 'immutable'; | |||
const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); | |||
const initialState = ImmutableMap(); | |||
export default function polls(state = initialState, action) { | |||
switch(action.type) { | |||
case POLLS_IMPORT: | |||
return importPolls(state, action.polls); | |||
case POLL_VOTE_SUCCESS: | |||
case POLL_FETCH_SUCCESS: | |||
return importPolls(state, [action.poll]); | |||
default: | |||
return state; | |||
} | |||
} |
@ -0,0 +1,95 @@ | |||
.poll { | |||
margin-top: 16px; | |||
font-size: 14px; | |||
li { | |||
margin-bottom: 10px; | |||
position: relative; | |||
} | |||
&__chart { | |||
position: absolute; | |||
top: 0; | |||
left: 0; | |||
height: 100%; | |||
display: inline-block; | |||
border-radius: 4px; | |||
background: darken($ui-primary-color, 14%); | |||
&.leading { | |||
background: $ui-highlight-color; | |||
} | |||
} | |||
&__text { | |||
position: relative; | |||
display: inline-block; | |||
padding: 6px 0; | |||
line-height: 18px; | |||
cursor: default; | |||
input[type=radio], | |||
input[type=checkbox] { | |||
display: none; | |||
} | |||
&.selectable { | |||
cursor: pointer; | |||
} | |||
} | |||
&__input { | |||
display: inline-block; | |||
position: relative; | |||
border: 1px solid $ui-primary-color; | |||
box-sizing: border-box; | |||
width: 18px; | |||
height: 18px; | |||
margin-right: 10px; | |||
top: -1px; | |||
border-radius: 4px; | |||
vertical-align: middle; | |||
&.active { | |||
border-color: $valid-value-color; | |||
background: $valid-value-color; | |||
} | |||
} | |||
&__number { | |||
display: inline-block; | |||
width: 36px; | |||
font-weight: 700; | |||
padding: 0 10px; | |||
text-align: right; | |||
} | |||
&__footer { | |||
padding-top: 6px; | |||
padding-bottom: 5px; | |||
color: $dark-text-color; | |||
} | |||
&__link { | |||
display: inline; | |||
background: transparent; | |||
padding: 0; | |||
margin: 0; | |||
border: 0; | |||
color: $dark-text-color; | |||
text-decoration: underline; | |||
&:hover, | |||
&:focus, | |||
&:active { | |||
text-decoration: none; | |||
} | |||
} | |||
.button { | |||
height: 36px; | |||
padding: 0 16px; | |||
margin-right: 10px; | |||
font-size: 14px; | |||
} | |||
} |
@ -0,0 +1,90 @@ | |||
# frozen_string_literal: true | |||
# == Schema Information | |||
# | |||
# Table name: polls | |||
# | |||
# id :bigint(8) not null, primary key | |||
# account_id :bigint(8) | |||
# status_id :bigint(8) | |||
# expires_at :datetime | |||
# options :string default([]), not null, is an Array | |||
# cached_tallies :bigint(8) default([]), not null, is an Array | |||
# multiple :boolean default(FALSE), not null | |||
# hide_totals :boolean default(FALSE), not null | |||
# votes_count :bigint(8) default(0), not null | |||
# last_fetched_at :datetime | |||
# created_at :datetime not null | |||
# updated_at :datetime not null | |||
# | |||
class Poll < ApplicationRecord | |||
include Expireable | |||
belongs_to :account | |||
belongs_to :status | |||
has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :destroy | |||
validates :options, presence: true | |||
validates :expires_at, presence: true, if: :local? | |||
validates_with PollValidator, if: :local? | |||
scope :attached, -> { where.not(status_id: nil) } | |||
scope :unattached, -> { where(status_id: nil) } | |||
before_validation :prepare_votes_count | |||
after_initialize :prepare_cached_tallies | |||
after_commit :reset_parent_cache, on: :update | |||
def loaded_options | |||
options.map.with_index { |title, key| Option.new(self, key.to_s, title, cached_tallies[key]) } | |||
end | |||
def unloaded_options | |||
options.map.with_index { |title, key| Option.new(self, key.to_s, title, nil) } | |||
end | |||
def possibly_stale? | |||
remote? && last_fetched_before_expiration? && time_passed_since_last_fetch? | |||
end | |||
delegate :local?, to: :account | |||
def remote? | |||
!local? | |||
end | |||
class Option < ActiveModelSerializers::Model | |||
attributes :id, :title, :votes_count, :poll | |||
def initialize(poll, id, title, votes_count) | |||
@poll = poll | |||
@id = id | |||
@title = title | |||
@votes_count = votes_count | |||
end | |||
end | |||
private | |||
def prepare_cached_tallies | |||
self.cached_tallies = options.map { 0 } if cached_tallies.empty? | |||
end | |||
def prepare_votes_count | |||
self.votes_count = cached_tallies.sum unless cached_tallies.empty? | |||
end | |||
def reset_parent_cache | |||
return if status_id.nil? | |||
Rails.cache.delete("statuses/#{status_id}") | |||
end | |||
def last_fetched_before_expiration? | |||
last_fetched_at.nil? || expires_at.nil? || last_fetched_at < expires_at | |||
end | |||
def time_passed_since_last_fetch? | |||
last_fetched_at.nil? || last_fetched_at < 1.minute.ago | |||
end | |||
end |
@ -0,0 +1,29 @@ | |||
# frozen_string_literal: true | |||
# == Schema Information | |||
# | |||
# Table name: poll_votes | |||
# | |||
# id :bigint(8) not null, primary key | |||
# account_id :bigint(8) | |||
# poll_id :bigint(8) | |||
# choice :integer default(0), not null | |||
# created_at :datetime not null | |||
# updated_at :datetime not null | |||
# | |||
class PollVote < ApplicationRecord | |||
belongs_to :account | |||
belongs_to :poll, inverse_of: :votes | |||
validates :choice, presence: true | |||
validates_with VoteValidator | |||
after_create_commit :increment_counter_cache | |||
private | |||
def increment_counter_cache | |||
poll.cached_tallies[choice] = (poll.cached_tallies[choice] || 0) + 1 | |||
poll.save | |||
end | |||
end |
@ -0,0 +1,7 @@ | |||
# frozen_string_literal: true | |||
class PollPolicy < ApplicationPolicy | |||
def vote? | |||
!current_account.blocking?(record.account) && !record.account.blocking?(current_account) | |||
end | |||
end |
@ -0,0 +1,48 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::VoteSerializer < ActiveModel::Serializer | |||
class NoteSerializer < ActiveModel::Serializer | |||
attributes :id, :type, :name, :attributed_to, | |||
:in_reply_to, :to | |||
def id | |||
nil | |||
end | |||
def type | |||
'Note' | |||
end | |||
def name | |||
object.poll.options[object.choice.to_i] | |||
end | |||
def attributed_to | |||
ActivityPub::TagManager.instance.uri_for(object.account) | |||
end | |||
def to | |||
ActivityPub::TagManager.instance.uri_for(object.poll.account) | |||
end | |||
end | |||
attributes :id, :type, :actor, :to | |||
has_one :object, serializer: ActivityPub::VoteSerializer::NoteSerializer | |||
def id | |||
nil | |||
end | |||
def type | |||
'Create' | |||
end | |||
def actor | |||
ActivityPub::TagManager.instance.uri_for(object.account) | |||
end | |||
def to | |||
ActivityPub::TagManager.instance.uri_for(object.poll.account) | |||
end | |||
end |
@ -0,0 +1,38 @@ | |||
# frozen_string_literal: true | |||
class REST::PollSerializer < ActiveModel::Serializer | |||
attributes :id, :expires_at, :expired, | |||
:multiple, :votes_count | |||
has_many :dynamic_options, key: :options | |||
attribute :voted, if: :current_user? | |||
def id | |||
object.id.to_s | |||
end | |||
def dynamic_options | |||
if !object.expired? && object.hide_totals? | |||
object.unloaded_options | |||
else | |||
object.loaded_options | |||
end | |||
end | |||
def expired | |||
object.expired? | |||
end | |||
def voted | |||
object.votes.where(account: current_user.account).exists? | |||
end | |||
def current_user? | |||
!current_user.nil? | |||
end | |||
class OptionSerializer < ActiveModel::Serializer | |||
attributes :title, :votes_count | |||
end | |||
end |
@ -0,0 +1,51 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::FetchRemotePollService < BaseService | |||
include JsonLdHelper | |||
def call(poll, on_behalf_of = nil) | |||
@json = fetch_resource(poll.status.uri, true, on_behalf_of) | |||
return unless supported_context? && expected_type? | |||
expires_at = begin | |||
if @json['closed'].is_a?(String) | |||
@json['closed'] | |||
elsif !@json['closed'].is_a?(FalseClass) | |||
Time.now.utc | |||
else | |||
@json['endTime'] | |||
end | |||
end | |||
items = begin | |||
if @json['anyOf'].is_a?(Array) | |||
@json['anyOf'] | |||
else | |||
@json['oneOf'] | |||
end | |||
end | |||
latest_options = items.map { |item| item['name'].presence || item['content'] } | |||
# If for some reasons the options were changed, it invalidates all previous | |||
# votes, so we need to remove them | |||
poll.votes.delete_all if latest_options != poll.options | |||
poll.update!( | |||
expires_at: expires_at, | |||
options: latest_options, | |||
cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } | |||
) | |||
end | |||
private | |||
def supported_context? | |||
super(@json) | |||
end | |||
def expected_type? | |||
equals_or_includes_any?(@json['type'], 'Question') | |||
end | |||
end |
@ -0,0 +1,40 @@ | |||
# frozen_string_literal: true | |||
class VoteService < BaseService | |||
include Authorization | |||
def call(account, poll, choices) | |||
authorize_with account, poll, :vote? | |||
@account = account | |||
@poll = poll | |||
@choices = choices | |||
@votes = [] | |||
ApplicationRecord.transaction do | |||
@choices.each do |choice| | |||
@votes << @poll.votes.create!(account: @account, choice: choice) | |||
end | |||
end | |||
return if @poll.account.local? | |||
@votes.each do |vote| | |||
ActivityPub::DeliveryWorker.perform_async( | |||
build_json(vote), | |||
@account.id, | |||
@poll.account.inbox_url | |||
) | |||
end | |||
end | |||
private | |||
def build_json(vote) | |||
ActiveModelSerializers::SerializableResource.new( | |||
vote, | |||
serializer: ActivityPub::VoteSerializer, | |||
adapter: ActivityPub::Adapter | |||
).to_json | |||
end | |||
end |
@ -0,0 +1,19 @@ | |||
# frozen_string_literal: true | |||
class PollValidator < ActiveModel::Validator | |||
MAX_OPTIONS = 4 | |||
MAX_OPTION_CHARS = 25 | |||
MAX_EXPIRATION = 7.days.freeze | |||
MIN_EXPIRATION = 1.day.freeze | |||
def validate(poll) | |||
current_time = Time.now.utc | |||
poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1 | |||
poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS | |||
poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS } | |||
poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size | |||
poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time >= MAX_EXPIRATION | |||
poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && poll.expires_at - current_time <= MIN_EXPIRATION | |||
end | |||
end |
@ -0,0 +1,13 @@ | |||
# frozen_string_literal: true | |||
class VoteValidator < ActiveModel::Validator | |||
def validate(vote) | |||
vote.errors.add(:base, I18n.t('polls.errors.expired')) if vote.poll.expired? | |||
if vote.poll.multiple? && vote.poll.votes.where(account: vote.account, choice: vote.choice).exists? | |||
vote.errors.add(:base, I18n.t('polls.errors.already_voted')) | |||
elsif vote.poll.votes.where(account: vote.account).exists? | |||
vote.errors.add(:base, I18n.t('polls.errors.already_voted')) | |||
end | |||
end | |||
end |
@ -0,0 +1,17 @@ | |||
class CreatePolls < ActiveRecord::Migration[5.2] | |||
def change | |||
create_table :polls do |t| | |||
t.belongs_to :account, foreign_key: { on_delete: :cascade } | |||
t.belongs_to :status, foreign_key: { on_delete: :cascade } | |||
t.datetime :expires_at | |||
t.string :options, null: false, array: true, default: [] | |||
t.bigint :cached_tallies, null: false, array: true, default: [] | |||
t.boolean :multiple, null: false, default: false | |||
t.boolean :hide_totals, null: false, default: false | |||
t.bigint :votes_count, null: false, default: 0 | |||
t.datetime :last_fetched_at | |||
t.timestamps | |||
end | |||
end | |||
end |
@ -0,0 +1,11 @@ | |||
class CreatePollVotes < ActiveRecord::Migration[5.2] | |||
def change | |||
create_table :poll_votes do |t| | |||
t.belongs_to :account, foreign_key: { on_delete: :cascade } | |||
t.belongs_to :poll, foreign_key: { on_delete: :cascade } | |||
t.integer :choice, null: false, default: 0 | |||
t.timestamps | |||
end | |||
end | |||
end |
@ -0,0 +1,5 @@ | |||
class AddPollIdToStatuses < ActiveRecord::Migration[5.2] | |||
def change | |||
add_column :statuses, :poll_id, :bigint | |||
end | |||
end |
@ -0,0 +1,34 @@ | |||
require 'rails_helper' | |||
RSpec.describe Api::V1::Polls::VotesController, type: :controller do | |||
render_views | |||
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } | |||
let(:scopes) { 'write:statuses' } | |||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } | |||
before { allow(controller).to receive(:doorkeeper_token) { token } } | |||
describe 'POST #create' do | |||
let(:poll) { Fabricate(:poll) } | |||
before do | |||
post :create, params: { poll_id: poll.id, choices: %w(1) } | |||
end | |||
it 'returns http success' do | |||
expect(response).to have_http_status(200) | |||
end | |||
it 'creates a vote' do | |||
vote = poll.votes.where(account: user.account).first | |||
expect(vote).to_not be_nil | |||
expect(vote.choice).to eq 1 | |||
end | |||
it 'updates poll tallies' do | |||
expect(poll.reload.cached_tallies).to eq [0, 1] | |||
end | |||
end | |||
end |
@ -0,0 +1,23 @@ | |||
require 'rails_helper' | |||
RSpec.describe Api::V1::PollsController, type: :controller do | |||
render_views | |||
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } | |||
let(:scopes) { 'read:statuses' } | |||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } | |||
before { allow(controller).to receive(:doorkeeper_token) { token } } | |||
describe 'GET #show' do | |||
let(:poll) { Fabricate(:poll) } | |||
before do | |||
get :show, params: { id: poll.id } | |||
end | |||
it 'returns http success' do | |||
expect(response).to have_http_status(200) | |||
end | |||
end | |||
end |
@ -0,0 +1,8 @@ | |||
Fabricator(:poll) do | |||
account | |||
status | |||
expires_at { 7.days.from_now } | |||
options %w(Foo Bar) | |||
multiple false | |||
hide_totals false | |||
end |
@ -0,0 +1,5 @@ | |||
Fabricator(:poll_vote) do | |||
account | |||
poll | |||
choice 0 | |||
end |
@ -0,0 +1,5 @@ | |||
require 'rails_helper' | |||
RSpec.describe Poll, type: :model do | |||
pending "add some examples to (or delete) #{__FILE__}" | |||
end |
@ -0,0 +1,5 @@ | |||
require 'rails_helper' | |||
RSpec.describe PollVote, type: :model do | |||
pending "add some examples to (or delete) #{__FILE__}" | |||
end |