* 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 mediamaster
@ -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 |