Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Copy in models from Hyperarchy and begin cleanup. Answer specs passing.

  • Loading branch information...
commit daa7664519936fff5bfaeba0db90aef42a98e7ff 1 parent ad5ab50
@nathansobo authored
Showing with 2,147 additions and 19 deletions.
  1. +1 −0  .gitignore
  2. +4 −0 Gemfile
  3. +26 −14 Gemfile.lock
  4. +97 −0 app/models/answer.rb
  5. +27 −0 app/models/event_observer.rb
  6. +17 −0 app/models/majority.rb
  7. +98 −0 app/models/question.rb
  8. +207 −0 app/models/ranking.rb
  9. +26 −0 app/models/sandbox.rb
  10. +42 −0 app/models/user.rb
  11. +38 −0 app/models/vote.rb
  12. +1 −2  config/application.rb
  13. +12 −0 config/environment.rb
  14. +61 −0 db/migrate/20120904162652_initial_schema.rb
  15. +69 −0 db/schema.rb
  16. +9 −0 lib/build_client_dataset.rb
  17. +74 −0 lib/prequel_extensions.rb
  18. +199 −0 spec/models/answer_spec.rb
  19. +66 −0 spec/models/event_observer_spec.rb
  20. +45 −0 spec/models/exposed_repository_spec.rb
  21. +306 −0 spec/models/question_spec.rb
  22. +310 −0 spec/models/ranking_spec.rb
  23. +242 −0 spec/models/user_spec.rb
  24. +32 −0 spec/models/vote_spec.rb
  25. +15 −2 spec/spec_helper.rb
  26. +21 −0 spec/support/blueprints.rb
  27. +51 −0 spec/support/controller_spec_methods.rb
  28. +13 −0 spec/support/model_spec_methods.rb
  29. +37 −0 spec/support/spec_methods.rb
  30. +1 −1  vendor/prequel
View
1  .gitignore
@@ -13,3 +13,4 @@
# Ignore all logfiles and tempfiles.
/log/*.log
/tmp
+.DS_Store
View
4 Gemfile
@@ -8,6 +8,7 @@ gem 'rails', '3.2.8'
gem 'pusher'
gem 'pg', '~> 0.11'
gem 'prequel', :path => 'vendor/prequel'
+gem 'rgl', :require => ['rgl/base', 'rgl/adjacency', 'rgl/topsort']
# Gems used only for assets and not required
# in production environments by default.
@@ -29,6 +30,9 @@ group :test, :development do
gem "rspec-rails", "~> 2.0"
gem 'dotenv'
gem 'activerecord-postgresql-adapter'
+ gem 'rr'
+ gem 'machinist'
+ gem 'faker'
end
# To use ActiveModel has_secure_password
View
40 Gemfile.lock
@@ -57,6 +57,8 @@ GEM
erubis (2.7.0)
execjs (1.4.0)
multi_json (~> 1.0)
+ faker (1.0.1)
+ i18n (~> 0.4)
hike (1.2.1)
i18n (0.6.1)
journey (1.0.4)
@@ -64,6 +66,7 @@ GEM
railties (>= 3.1.0, < 5.0)
thor (~> 0.14)
json (1.7.5)
+ machinist (2.0)
mail (2.4.4)
i18n (>= 0.4.0)
mime-types (~> 1.16)
@@ -100,30 +103,35 @@ GEM
rake (0.9.2.2)
rdoc (3.12)
json (~> 1.4)
- rspec (2.6.0)
- rspec-core (~> 2.6.0)
- rspec-expectations (~> 2.6.0)
- rspec-mocks (~> 2.6.0)
- rspec-core (2.6.4)
- rspec-expectations (2.6.0)
- diff-lcs (~> 1.1.2)
- rspec-mocks (2.6.0)
- rspec-rails (2.6.1)
- actionpack (~> 3.0)
- activesupport (~> 3.0)
- railties (~> 3.0)
- rspec (~> 2.6.0)
+ rgl (0.4.0)
+ rake
+ stream (>= 0.5)
+ rr (1.0.4)
+ rspec (2.11.0)
+ rspec-core (~> 2.11.0)
+ rspec-expectations (~> 2.11.0)
+ rspec-mocks (~> 2.11.0)
+ rspec-core (2.11.1)
+ rspec-expectations (2.11.2)
+ diff-lcs (~> 1.1.3)
+ rspec-mocks (2.11.2)
+ rspec-rails (2.11.0)
+ actionpack (>= 3.0)
+ activesupport (>= 3.0)
+ railties (>= 3.0)
+ rspec (~> 2.11.0)
sass (3.2.1)
sass-rails (3.2.5)
railties (~> 3.2.0)
sass (>= 3.1.10)
tilt (~> 1.3)
- sequel (3.28.0)
+ sequel (3.39.0)
signature (0.1.4)
sprockets (2.1.3)
hike (~> 1.2)
rack (~> 1.0)
tilt (~> 1.1, != 1.3.0)
+ stream (0.5)
thor (0.16.0)
tilt (1.3.3)
treetop (1.4.10)
@@ -142,12 +150,16 @@ DEPENDENCIES
activerecord-postgresql-adapter
coffee-rails (~> 3.2.1)
dotenv
+ faker
jquery-rails
+ machinist
monarch!
pg (~> 0.11)
prequel!
pusher
rails (= 3.2.8)
+ rgl
+ rr
rspec-rails (~> 2.0)
sass-rails (~> 3.2.3)
uglifier (>= 1.0.3)
View
97 app/models/answer.rb
@@ -0,0 +1,97 @@
+class Answer < Prequel::Record
+ column :id, :integer
+ column :body, :string
+ column :question_id, :integer
+ column :creator_id, :integer
+ column :position, :integer
+ column :created_at, :datetime
+ column :updated_at, :datetime
+
+ belongs_to :question
+ belongs_to :creator, :class_name => "User"
+ has_many :rankings
+
+ def can_update_or_destroy?
+ creator_id == current_user.id
+ end
+ alias can_update? can_update_or_destroy?
+ alias can_destroy? can_update_or_destroy?
+
+ def create_whitelist
+ [:body, :question_id]
+ end
+
+ def update_whitelist
+ [:body]
+ end
+
+ def before_create
+ ensure_body_within_limit
+ question.lock
+ self.creator ||= current_user
+ end
+
+ def before_update(changeset)
+ ensure_body_within_limit if changeset[:body]
+ end
+
+ def ensure_body_within_limit
+ raise SecurityError, "Body exceeds 140 characters" if body.length > 140
+ end
+
+ def after_create
+ update(:position => 1) if other_answers.empty?
+ other_answers.each do |other_answer|
+ Majority.create({:winner => self, :loser => other_answer, :question_id => question_id})
+ Majority.create({:winner => other_answer, :loser => self, :question_id => question_id})
+ end
+
+ victories_over(question.negative_answer_ranking_counts).update(:pro_count => :times_ranked)
+ victories_over(question.positive_answer_ranking_counts).update(:con_count => :times_ranked)
+ defeats_by(question.positive_answer_ranking_counts).update(:pro_count => :times_ranked)
+ defeats_by(question.negative_answer_ranking_counts).update(:con_count => :times_ranked)
+
+ question.compute_global_ranking
+ question.unlock
+ end
+
+ def before_destroy
+ question.lock
+ rankings.each do |ranking|
+ ranking.suppress_vote_update = true
+ ranking.destroy
+ end
+ winning_majorities.each(&:destroy)
+ losing_majorities.each(&:destroy)
+ end
+
+ def after_destroy
+ question.unlock
+ end
+
+ def other_answers
+ question.answers.where(Answer[:id].neq(id))
+ end
+
+ def victories_over(other_answer_ranking_counts)
+ winning_majorities.
+ join(other_answer_ranking_counts, :loser_id => :answer_id)
+ end
+
+ def defeats_by(other_answer_ranking_counts)
+ losing_majorities.
+ join(other_answer_ranking_counts, :winner_id => :answer_id)
+ end
+
+ def winning_majorities
+ question.majorities.where(:winner_id => id)
+ end
+
+ def losing_majorities
+ question.majorities.where(:loser_id => id)
+ end
+
+ def extra_records_for_create_events
+ [creator]
+ end
+end
View
27 app/models/event_observer.rb
@@ -0,0 +1,27 @@
+require 'build_client_dataset'
+
+module EventObserver
+ include BuildClientDataset
+ extend self
+
+ def observe(*record_classes)
+ record_classes.each do |record_class|
+ record_class.on_create do |record|
+ event = ['create', record.table.name, record.wire_representation, build_client_dataset(record.extra_records_for_create_events)]
+ post_event(event)
+ end
+ record_class.on_update do |record, changeset|
+ event = ['update', record.table.name, record.id, changeset.wire_representation]
+ post_event(event)
+ end
+ record_class.on_destroy do |record|
+ event = ['destroy', record.table.name, record.id]
+ post_event(event)
+ end
+ end
+ end
+
+ def post_event(event)
+ puts "PUSHER MESSAGE", event.to_json
+ end
+end
View
17 app/models/majority.rb
@@ -0,0 +1,17 @@
+class Majority < Prequel::Record
+ column :id, :integer
+ column :question_id, :integer
+ column :winner_id, :integer
+ column :loser_id, :integer
+ column :pro_count, :integer, :default => 0
+ column :con_count, :integer, :default => 0
+ column :winner_created_at, :datetime
+
+ belongs_to :question
+ belongs_to :winner, :class_name => "Answer"
+ belongs_to :loser, :class_name => "Answer"
+
+ def before_create
+ self.winner_created_at = winner.created_at
+ end
+end
View
98 app/models/question.rb
@@ -0,0 +1,98 @@
+class Question < Prequel::Record
+ column :id, :integer
+ column :organization_id, :integer
+ column :creator_id, :integer
+ column :body, :string
+ column :details, :string
+ column :vote_count, :integer, :default => 0
+ column :score, :float
+ column :created_at, :datetime
+ column :updated_at, :datetime
+
+ has_many :answers
+ has_many :votes
+ has_many :rankings
+ has_many :majorities
+
+ belongs_to :creator, :class_name => "User"
+
+ def can_update_or_destroy?
+ creator_id == current_user.id
+ end
+ alias can_update? can_update_or_destroy?
+ alias can_destroy? can_update_or_destroy?
+
+ def create_whitelist
+ [:body, :details]
+ end
+
+ def update_whitelist
+ [:body, :details]
+ end
+
+ def before_create
+ ensure_body_within_limit
+ self.creator ||= current_user
+ end
+
+ def ensure_body_within_limit
+ raise SecurityError, "Body exceeds 140 characters" if body.length > 140
+ end
+
+ def before_update(changeset)
+ ensure_body_within_limit if changeset[:body]
+ end
+
+ def before_destroy
+ answers.each(&:destroy)
+ end
+
+ def compute_global_ranking
+ already_processed = []
+ graph = RGL::DirectedAdjacencyGraph.new
+
+ majorities.order_by(Majority[:pro_count].desc, Majority[:con_count].asc, Majority[:winner_created_at].asc).each do |majority|
+ winner_id = majority.winner_id
+ loser_id = majority.loser_id
+ next if already_processed.include?([loser_id, winner_id])
+ already_processed.push([winner_id, loser_id])
+ graph.add_edge(winner_id, loser_id)
+ graph.remove_edge(winner_id, loser_id) unless graph.acyclic?
+ end
+
+ graph.topsort_iterator.each_with_index do |answer_id, index|
+ answer = answers.find(answer_id)
+ answer.update!(:position => index + 1)
+ end
+
+ update!(:updated_at => Time.now)
+ end
+
+ def positive_rankings
+ rankings.where(Ranking[:position].gt(0))
+ end
+
+ def negative_rankings
+ rankings.where(Ranking[:position].lt(0))
+ end
+
+ def positive_answer_ranking_counts
+ times_each_answer_is_ranked(positive_rankings)
+ end
+
+ def negative_answer_ranking_counts
+ times_each_answer_is_ranked(negative_rankings)
+ end
+
+ def times_each_answer_is_ranked(relation)
+ relation.
+ group_by(:answer_id).
+ project(:answer_id, Ranking[:id].count.as(:times_ranked))
+ end
+
+ def ranked_answers
+ answers.
+ join(rankings).
+ project(Answer)
+ end
+end
View
207 app/models/ranking.rb
@@ -0,0 +1,207 @@
+class Ranking < Prequel::Record
+ column :id, :integer
+ column :user_id, :integer
+ column :question_id, :integer
+ column :answer_id, :integer
+ column :vote_id, :integer
+ column :position, :float
+ column :created_at, :datetime
+ column :updated_at, :datetime
+
+ belongs_to :user
+ belongs_to :answer
+ belongs_to :question
+ belongs_to :vote
+
+ attr_accessor :suppress_vote_update
+
+ def can_create_or_update?
+ false
+ end
+ alias can_create? can_create_or_update?
+ alias can_update? can_create_or_update?
+
+ def can_destroy?
+ user_id == current_user.id
+ end
+
+ def before_create
+ self.question_id = answer.question_id
+ question.lock
+ self.vote = question.votes.find_or_create(:user_id => user_id)
+ vote.updated
+ end
+
+ def after_create
+ if position > 0
+ increment_victories_over(lower_positive_rankings_by_same_user)
+ decrement_defeats_by(lower_positive_rankings_by_same_user)
+ increment_victories_over(answers_not_ranked_by_same_user)
+ else
+ increment_defeats_by(higher_negative_rankings_by_same_user)
+ decrement_victories_over(higher_negative_rankings_by_same_user)
+ increment_defeats_by(answers_not_ranked_by_same_user)
+ end
+
+ question.compute_global_ranking
+ question.unlock
+ end
+
+ def before_update(changeset)
+ question.lock
+ end
+
+ def after_update(changeset)
+ return unless changeset.changed?(:position)
+ old_position = changeset.old(:position)
+ if position > old_position
+ after_ranking_moved_up(old_position)
+ else
+ after_ranking_moved_down(old_position)
+ end
+
+ question.votes.find(:user_id => user_id).updated
+ question.compute_global_ranking
+ question.unlock
+ end
+
+ def after_ranking_moved_up(old_position)
+ previously_higher_rankings = lower_rankings_by_same_user.where(:position.gt(old_position))
+
+ increment_victories_over(previously_higher_rankings)
+ decrement_defeats_by(previously_higher_rankings)
+
+ if position > 0 && old_position < 0
+ increment_victories_over(answers_not_ranked_by_same_user)
+ decrement_defeats_by(answers_not_ranked_by_same_user)
+ end
+ end
+
+ def after_ranking_moved_down(old_position)
+ previously_lower_rankings = higher_rankings_by_same_user.where(:position.lt(old_position))
+ decrement_victories_over(previously_lower_rankings)
+ increment_defeats_by(previously_lower_rankings)
+
+ if position < 0 && old_position > 0
+ decrement_victories_over(answers_not_ranked_by_same_user)
+ increment_defeats_by(answers_not_ranked_by_same_user)
+ end
+ end
+
+ def before_destroy
+ question.lock
+ end
+
+ def after_destroy
+ if position > 0
+ decrement_victories_over(lower_positive_rankings_by_same_user)
+ increment_defeats_by(lower_positive_rankings_by_same_user)
+ decrement_victories_over(answers_not_ranked_by_same_user)
+ else
+ increment_victories_over(higher_negative_rankings_by_same_user)
+ decrement_defeats_by(higher_negative_rankings_by_same_user)
+ decrement_defeats_by(answers_not_ranked_by_same_user)
+ end
+
+ if rankings_by_same_user.empty?
+ vote.destroy
+ elsif !suppress_vote_update
+ vote.updated
+ end
+ question.compute_global_ranking
+ question.unlock
+ end
+
+ def increment_victories_over(rankings_or_answers)
+ victories_over(rankings_or_answers).increment(:pro_count)
+ defeats_by(rankings_or_answers).increment(:con_count)
+ end
+
+ def decrement_victories_over(rankings_or_answers)
+ victories_over(rankings_or_answers).decrement(:pro_count)
+ defeats_by(rankings_or_answers).decrement(:con_count)
+ end
+
+ def increment_defeats_by(rankings_or_answers)
+ defeats_by(rankings_or_answers).increment(:pro_count)
+ victories_over(rankings_or_answers).increment(:con_count)
+ end
+
+ def decrement_defeats_by(rankings_or_answers)
+ defeats_by(rankings_or_answers).decrement(:pro_count)
+ victories_over(rankings_or_answers).decrement(:con_count)
+ end
+
+ def victories_over(rankings_or_answers)
+ majorities_where_ranked_answer_is_winner.
+ join(rankings_or_answers, :loser_id => answer_id_join_column(rankings_or_answers))
+ end
+
+ def defeats_by(rankings_or_answers)
+ majorities_where_ranked_answer_is_loser.
+ join(rankings_or_answers, :winner_id => answer_id_join_column(rankings_or_answers))
+ end
+
+ def answer_id_join_column(rankings_or_answers)
+ rankings_or_answers.get_column(:answer_id) ? :answer_id : Answer[:id]
+ end
+
+ def rankings_by_same_user
+ Ranking.where(:user_id => user_id, :question_id => question_id)
+ end
+
+ def higher_rankings_by_same_user
+ rankings_by_same_user.where(:position.gt(position))
+ end
+
+ def lower_rankings_by_same_user
+ rankings_by_same_user.where(:position.lt(position))
+ end
+
+ def positive_rankings_by_same_user
+ rankings_by_same_user.where(:position.gt(0))
+ end
+
+ def negative_rankings_by_same_user
+ rankings_by_same_user.where(:position.lt(0))
+ end
+
+ def higher_positive_rankings_by_same_user
+ positive_rankings_by_same_user.where(:position.gt(position))
+ end
+
+ def lower_positive_rankings_by_same_user
+ positive_rankings_by_same_user.where(:position.lt(position))
+ end
+
+ def higher_negative_rankings_by_same_user
+ negative_rankings_by_same_user.where(:position.gt(position))
+ end
+
+ def lower_negative_rankings_by_same_user
+ negative_rankings_by_same_user.where(:position.lt(position))
+ end
+
+ def majorities_where_ranked_answer_is_winner
+ Majority.where(:winner_id => answer_id)
+ end
+
+ def majorities_where_ranked_answer_is_loser
+ Majority.where(:loser_id => answer_id)
+ end
+
+ def all_rankings_for_same_answer
+ Ranking.where(:answer_id => answer_id)
+ end
+
+ def all_answers_in_question
+ Answer.where(:question_id => question_id)
+ end
+
+ def answers_not_ranked_by_same_user
+ all_answers_in_question.
+ left_join(rankings_by_same_user).
+ where(Ranking[:id] => nil).
+ project(Answer)
+ end
+end
View
26 app/models/sandbox.rb
@@ -0,0 +1,26 @@
+class Sandbox < Prequel::Sandbox
+ attr_reader :user
+ def initialize(user)
+ @user = user
+ end
+
+ expose :users do
+ User.table
+ end
+
+ expose :questions do
+ Question.table
+ end
+
+ expose :answers do
+ Answer.table
+ end
+
+ expose :votes do
+ Vote.table
+ end
+
+ expose :rankings do
+ Ranking.table
+ end
+end
View
42 app/models/user.rb
@@ -0,0 +1,42 @@
+class User < Prequel::Record
+ column :id, :integer
+ column :full_name, :string
+ column :email_address, :string
+ column :oauth_access_token, :string
+
+ synthetic_column :email_hash, :string
+
+ has_many :votes
+ has_many :rankings
+ has_many :questions
+ has_many :answers, :foreign_key => :creator_id
+
+ validates_uniqueness_of :email_address, :message => "There is already an account with that email address."
+
+ def can_update_or_destroy?
+ current_user == self
+ end
+ alias can_update? can_update_or_destroy?
+ alias can_destroy? can_update_or_destroy?
+
+ def create_whitelist
+ [:full_name, :email_address, :password]
+ end
+
+ def update_whitelist
+ [:full_name, :email_address, :password]
+ end
+
+ # dont send email address to another user unless they are an admin or owner
+ def read_blacklist
+ [:oauth_access_token]
+ end
+
+ def initial_repository_contents
+ [self]
+ end
+
+ def email_hash
+ Digest::MD5.hexdigest(email_address.downcase) if email_address
+ end
+end
View
38 app/models/vote.rb
@@ -0,0 +1,38 @@
+class Vote < Prequel::Record
+ column :id, :integer
+ column :user_id, :integer
+ column :question_id, :integer
+ column :created_at, :datetime
+ column :updated_at, :datetime
+
+ belongs_to :user
+ belongs_to :question
+
+ def can_mutate?
+ false
+ end
+ alias can_create? can_mutate?
+ alias can_update? can_mutate?
+ alias can_destroy? can_mutate?
+
+ # note: this approach to incrementing / decrementing is not atomic!
+ # but currently plan to serialize all operations per question so it's ok
+ # we want to go through the record so the update gets broadcast
+ def after_create
+ question.vote_count = question.vote_count + 1
+ question.save
+ end
+
+ def after_destroy
+ question.vote_count -= 1
+ question.save
+ end
+
+ def updated
+ update(:updated_at => Time.now)
+ end
+
+ def extra_records_for_create_events
+ [user]
+ end
+end
View
3  config/application.rb
@@ -1,13 +1,12 @@
require File.expand_path('../boot', __FILE__)
# Pick the frameworks you want:
+require "active_record/railtie" # keep it for the db rake tasks only
require "action_controller/railtie"
require "action_mailer/railtie"
require "active_resource/railtie"
require "sprockets/railtie"
require "rails/test_unit/railtie"
-# We just want ActiveRecord for its Rake tasks
-require "active_record/railtie" if Rails.env.development?
if defined?(Bundler)
# If you precompile assets before deploying to production, use this line
View
12 config/environment.rb
@@ -3,3 +3,15 @@
# Initialize the rails application
Decider::Application.initialize!
+
+require 'prequel_extensions'
+
+class FakeRedis
+ def lock(name)
+ end
+
+ def unlock(name)
+ end
+end
+
+$redis = FakeRedis.new
View
61 db/migrate/20120904162652_initial_schema.rb
@@ -0,0 +1,61 @@
+class InitialSchema < ActiveRecord::Migration
+ def up
+ create_table :answers do |t|
+ t.string :body
+ t.integer :question_id
+ t.integer :creator_id
+ t.integer :position
+ t.integer :comment_count
+ t.timestamps
+ end
+
+ create_table :majorities do |t|
+ t.integer :question_id
+ t.integer :winner_id
+ t.integer :loser_id
+ t.integer :pro_count, :default => 0
+ t.integer :con_count, :default => 0
+ t.datetime :winner_created_at
+ end
+
+ create_table :questions do |t|
+ t.integer :organization_id
+ t.integer :creator_id
+ t.string :body
+ t.string :details
+ t.integer :vote_count
+ t.float :score
+ t.timestamps
+ end
+
+ create_table :rankings do |t|
+ t.integer :user_id
+ t.integer :question_id
+ t.integer :answer_id
+ t.integer :vote_id
+ t.float :position
+ t.timestamps
+ end
+
+ create_table :users do |t|
+ t.string :full_name
+ t.string :email_address
+ t.string :oauth_access_token
+ end
+
+ create_table :votes do |t|
+ t.integer :user_id
+ t.integer :question_id
+ t.timestamps
+ end
+ end
+
+ def down
+ drop_table :answers
+ drop_table :majorities
+ drop_table :questions
+ drop_table :rankings
+ drop_table :users
+ drop_table :votes
+ end
+end
View
69 db/schema.rb
@@ -0,0 +1,69 @@
+# encoding: UTF-8
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# Note that this schema.rb definition is the authoritative source for your
+# database schema. If you need to create the application database on another
+# system, you should be using db:schema:load, not running all the migrations
+# from scratch. The latter is a flawed and unsustainable approach (the more migrations
+# you'll amass, the slower it'll run and the greater likelihood for issues).
+#
+# It's strongly recommended to check this file into your version control system.
+
+ActiveRecord::Schema.define(:version => 20120904162652) do
+
+ create_table "answers", :force => true do |t|
+ t.string "body"
+ t.integer "question_id"
+ t.integer "creator_id"
+ t.integer "position"
+ t.integer "comment_count"
+ t.datetime "created_at", :null => false
+ t.datetime "updated_at", :null => false
+ end
+
+ create_table "majorities", :force => true do |t|
+ t.integer "question_id"
+ t.integer "winner_id"
+ t.integer "loser_id"
+ t.integer "pro_count", :default => 0
+ t.integer "con_count", :default => 0
+ t.datetime "winner_created_at"
+ end
+
+ create_table "questions", :force => true do |t|
+ t.integer "organization_id"
+ t.integer "creator_id"
+ t.string "body"
+ t.string "details"
+ t.integer "vote_count"
+ t.float "score"
+ t.datetime "created_at", :null => false
+ t.datetime "updated_at", :null => false
+ end
+
+ create_table "rankings", :force => true do |t|
+ t.integer "user_id"
+ t.integer "question_id"
+ t.integer "answer_id"
+ t.integer "vote_id"
+ t.float "position"
+ t.datetime "created_at", :null => false
+ t.datetime "updated_at", :null => false
+ end
+
+ create_table "users", :force => true do |t|
+ t.string "full_name"
+ t.string "email_address"
+ t.string "oauth_access_token"
+ end
+
+ create_table "votes", :force => true do |t|
+ t.integer "user_id"
+ t.integer "question_id"
+ t.datetime "created_at", :null => false
+ t.datetime "updated_at", :null => false
+ end
+
+end
View
9 lib/build_client_dataset.rb
@@ -0,0 +1,9 @@
+module BuildClientDataset
+ def build_client_dataset(*records_or_relations)
+ (Hash.new {|h,k| h[k] = {}}).tap do |dataset|
+ Array(records_or_relations).flatten.each do |r|
+ r.add_to_client_dataset(dataset)
+ end
+ end
+ end
+end
View
74 lib/prequel_extensions.rb
@@ -0,0 +1,74 @@
+module Prequel
+ class Session
+ attr_accessor :current_user
+ end
+
+ class Record
+ def self.raw_create(attributes={})
+ new(attributes).tap(&:raw_save)
+ end
+
+ def raw_update(attributes={})
+ soft_update(attributes)
+ raw_save
+ end
+
+ def raw_save
+ if persisted?
+ initial_changeset = build_changeset
+ return true unless dirty?
+ self.updated_at = Time.now if fields_by_name.has_key?(:updated_at)
+
+ dirty_fields = dirty_field_values
+ final_changeset = build_changeset
+ table.where(:id => id).update(dirty_fields) unless dirty_fields.empty?
+ mark_clean
+ else
+ self.created_at = Time.now if fields_by_name.has_key?(:created_at)
+ self.updated_at = Time.now if fields_by_name.has_key?(:updated_at)
+ self.id = (DB[table.name] << field_values_without_nil_id)
+ Prequel.session[table.name][id] = self
+ mark_clean
+ end
+ true
+ end
+
+ def current_user
+ Prequel.session.current_user
+ end
+
+ def extra_records_for_create_events
+ []
+ end
+
+ def lock_name
+ @lock_name ||= "/#{table.name}/#{id}"
+ end
+
+ def lock
+ $redis.lock(lock_name) if incr_lock_depth == 1
+ end
+
+ def unlock
+ $redis.unlock(lock_name) if decr_lock_depth == 0
+ end
+
+ def incr_lock_depth
+ Prequel.session.lock_depth[lock_name] += 1
+ end
+
+ def decr_lock_depth
+ Prequel.session.lock_depth[lock_name] -= 1
+ end
+
+ def can_update_columns?(columns)
+ (columns - (update_whitelist - update_blacklist)).empty?
+ end
+ end
+
+ class Session
+ def lock_depth
+ @lock_depth ||= Hash.new {|h,k| h[k] = 0}
+ end
+ end
+end
View
199 spec/models/answer_spec.rb
@@ -0,0 +1,199 @@
+require 'spec_helper'
+
+module Models
+ describe Answer do
+ attr_reader :question, :creator, :answer
+ before do
+ @question = Question.make!
+ @creator = User.make!
+ set_current_user(creator)
+ end
+
+ describe "life-cycle hooks" do
+ before do
+ Answer.clear
+ end
+
+ describe "before create" do
+ it "assigns the creator to the Model::Record.current_user" do
+ answer = question.answers.create(:body => "foo")
+ answer.creator.should == creator
+ end
+ end
+
+ describe "after create" do
+ def verify_majority(winner, loser, question)
+ majority = Majority.find(:winner => winner, :loser => loser, :question => question)
+ majority.should_not be_nil
+ majority.winner_created_at.to_i.should == winner.created_at.to_i
+ end
+
+ it "creates a winning and losing majority every pairing of the created answer with other answers" do
+ question.answers.should be_empty
+
+ falafel = question.answers.make!(:body => "Falafel")
+ tacos = question.answers.make!(:body => "Tacos")
+
+ verify_majority(falafel, tacos, question)
+ verify_majority(tacos, falafel, question)
+
+ fish = question.answers.make!(:body => "Fish")
+
+ verify_majority(falafel, fish, question)
+ verify_majority(tacos, fish, question)
+ verify_majority(fish, falafel, question)
+ verify_majority(fish, tacos, question)
+ end
+
+ it "makes the new answer lose to every positively ranked answer and win over every negatively ranked one, then recomputes the question results" do
+ user_1 = User.make!
+ user_2 = User.make!
+ user_3 = User.make!
+
+ _3_up_0_down = question.answers.make!(:body => "3 Up - 0 Down")
+ _2_up_1_down = question.answers.make!(:body => "2 Up - 1 Down")
+ _1_up_2_down = question.answers.make!(:body => "1 Up - 2 Down")
+ _0_up_3_down = question.answers.make!(:body => "0 Up - 3 Down")
+ unranked = question.answers.make!(:body => "Unranked")
+
+ question.rankings.create(:user => user_1, :answer => _3_up_0_down, :position => 64)
+ question.rankings.create(:user => user_1, :answer => _2_up_1_down, :position => 32)
+ question.rankings.create(:user => user_1, :answer => _1_up_2_down, :position => 16)
+ question.rankings.create(:user => user_1, :answer => _0_up_3_down, :position => -64)
+
+ question.rankings.create(:user => user_2, :answer => _3_up_0_down, :position => 64)
+ question.rankings.create(:user => user_2, :answer => _2_up_1_down, :position => 32)
+ question.rankings.create(:user => user_2, :answer => _1_up_2_down, :position => -32)
+ question.rankings.create(:user => user_2, :answer => _0_up_3_down, :position => -64)
+
+ question.rankings.create(:user => user_3, :answer => _3_up_0_down, :position => 64)
+ question.rankings.create(:user => user_3, :answer => _2_up_1_down, :position => -16)
+ question.rankings.create(:user => user_3, :answer => _1_up_2_down, :position => -32)
+ question.rankings.create(:user => user_3, :answer => _0_up_3_down, :position => -64)
+
+ mock.proxy(question).compute_global_ranking
+ answer = question.answers.make!(:body => "Alpaca")
+ # new answer is tied with 'Unranked' so could go either before it or after it
+ # until we handle ties, but it should be less than the negatively ranked answers
+ answer.position.should be < 5
+
+ find_majority(_3_up_0_down, answer).pro_count.should == 3
+ find_majority(_3_up_0_down, answer).con_count.should == 0
+ find_majority(answer, _3_up_0_down).pro_count.should == 0
+ find_majority(answer, _3_up_0_down).con_count.should == 3
+
+ find_majority(_2_up_1_down, answer).pro_count.should == 2
+ find_majority(_2_up_1_down, answer).con_count.should == 1
+ find_majority(answer, _2_up_1_down).pro_count.should == 1
+ find_majority(answer, _2_up_1_down).con_count.should == 2
+
+ find_majority(_1_up_2_down, answer).pro_count.should == 1
+ find_majority(_1_up_2_down, answer).con_count.should == 2
+ find_majority(answer, _1_up_2_down).pro_count.should == 2
+ find_majority(answer, _1_up_2_down).con_count.should == 1
+
+ find_majority(_0_up_3_down, answer).pro_count.should == 0
+ find_majority(_0_up_3_down, answer).con_count.should == 3
+ find_majority(answer, _0_up_3_down).pro_count.should == 3
+ find_majority(answer, _0_up_3_down).con_count.should == 0
+
+ find_majority(unranked, answer).pro_count.should == 0
+ find_majority(unranked, answer).con_count.should == 0
+ find_majority(answer, unranked).pro_count.should == 0
+ find_majority(answer, unranked).con_count.should == 0
+ end
+
+ it "gives the answer a position of 1 if they are the only answer" do
+ answer = question.answers.make!(:body => "Only")
+ question.answers.size.should == 1
+ answer.position.should == 1
+ end
+ end
+
+ describe "#before_destroy" do
+ it "destroys any rankings and majorities associated with the answer, but does not change the updated_at time of associated votes" do
+ user_1 = User.make!
+ user_2 = User.make!
+
+ answer_1 = question.answers.make!(:body => "foo")
+ answer_2 = question.answers.make!(:body => "bar")
+
+ freeze_time
+ voting_time = Time.now
+
+ question.rankings.create(:user => user_1, :answer => answer_1, :position => 64)
+ question.rankings.create(:user => user_1, :answer => answer_2, :position => 32)
+ question.rankings.create(:user => user_2, :answer => answer_1, :position => 32)
+
+ Ranking.where(:answer_id => answer_1.id).size.should == 2
+ Majority.where(:winner_id => answer_1.id).size.should == 1
+ Majority.where(:loser_id => answer_1.id).size.should == 1
+
+ question.votes.size.should == 2
+ question.votes.each do |vote|
+ vote.updated_at.should == Time.now
+ end
+
+ jump(1.minute)
+
+ answer_1.destroy
+
+ Ranking.where(:answer_id => answer_1.id).should be_empty
+ Majority.where(:winner_id => answer_1.id).should be_empty
+ Majority.where(:loser_id => answer_1.id).should be_empty
+
+ question.votes.size.should == 1
+ question.votes.first.updated_at.should == voting_time
+ end
+ end
+ end
+
+ describe "#extra_records_for_create_events" do
+ it "contains the answer's creator" do
+ answer = Answer.make!
+ answer.extra_records_for_create_events.should == [creator]
+ end
+ end
+
+ describe "security" do
+ attr_reader :non_owner
+
+ before do
+ @non_owner = User.make!
+ @answer = question.answers.make(:body => "Hey you!")
+ end
+
+ describe "body length limit" do
+ it "raises a security error if trying to create or update with a body longer than 140 chars" do
+ long_body = "x" * 145
+
+ expect {
+ Answer.make!(:body => long_body)
+ }.to raise_error(SecurityError)
+
+ expect {
+ Answer.make!.update(:body => long_body)
+ }.to raise_error(SecurityError)
+ end
+ end
+
+ describe "#can_update? and #can_destroy?" do
+ specify "only the answer creator can destroy it or update its body and details" do
+ answer = Answer.make!
+ answer.creator.should == creator
+
+ set_current_user(non_owner)
+ answer.can_update?.should be_false
+ answer.can_destroy?.should be_false
+
+ set_current_user(creator)
+ answer.can_update?.should be_true
+ answer.can_destroy?.should be_true
+
+ # no one can update properties other than body and details
+ answer.can_update_columns?([:question_id, :creator_id, :position]).should be_false
+ end
+ end
+ end
+ end
+end
View
66 spec/models/event_observer_spec.rb
@@ -0,0 +1,66 @@
+require "spec_helper"
+
+describe EventObserver do
+ describe "#observe" do
+ before do
+ stub(EventObserver).post
+ end
+
+ let(:events) { [] }
+
+ it "causes all events on the given model classes to be sent to the appropriate channels on the socket server" do
+ EventObserver.observe(User, Organization, Question)
+
+ freeze_time
+ org1 = Organization.make
+ org2 = Organization.make
+ jump 1.minute
+
+ expect_event(org1)
+ org1.update(:name => 'New Org Name', :description => 'New Org Description')
+ events.shift.should == ["update", "organizations", org1.id, {"name"=>"New Org Name", "description"=>"New Org Description", 'updated_at' => Time.now.to_millis}]
+
+ expect_event(org1)
+ question = org1.questions.make
+ events.shift.should == ["create", "questions", question.wire_representation, {}]
+
+ expect_event(org1) # 2 events, 1 for the question count update and 1 for the destroy
+ expect_event(org1)
+ question.destroy
+ events.shift.should == ["update", "organizations", org1.id, {"question_count"=>0}]
+ events.shift.should == ["destroy", "questions", question.id]
+
+ user = org1.make_member
+ org2.memberships.make(:user => user)
+
+ expect_event(org1)
+ expect_event(org2)
+
+ user.update(:first_name => "MartyPrime")
+
+ event = ["update", "users", user.id, {"first_name"=>"MartyPrime"}]
+ events.should == [event, event]
+ end
+
+ it "sends extra records for create events if desired" do
+ extra_question = Question.make
+ org1 = Organization.make
+ instance_of(Question).extra_records_for_create_events { [extra_question] }
+
+ EventObserver.observe(Question)
+
+ freeze_time
+
+ expect_event(org1)
+ question = org1.questions.make
+ extra_records = RecordsWrapper.new(events.shift.last)
+ extra_records.should include(extra_question)
+ end
+
+ def expect_event(organization)
+ mock(EventObserver).post(organization.event_url, is_a(Hash)) do |url, options|
+ events.push(JSON.parse(options[:params][:message]))
+ end
+ end
+ end
+end
View
45 spec/models/exposed_repository_spec.rb
@@ -0,0 +1,45 @@
+require 'spec_helper'
+
+describe Sandbox do
+
+ attr_reader :question, :repository, :user_1, :user_2, :answer_1, :answer_2
+
+ before do
+ org = Organization.make
+ current_user = org.make_member
+
+ @user_1 = org.make_member
+ @user_2 = org.make_member
+
+ @question = org.questions.make
+ @answer_1 = question.answers.make(:creator => user_1)
+ @answer_2 = question.answers.make(:creator => user_2)
+
+ @repository = Sandbox.new(current_user)
+ end
+
+ it "correctly interprets a join from answers on a given question to their users" do
+ wire_reps = [
+ {"type" => "inner_join",
+ "left_operand" =>
+ {"type" => "selection",
+ "operand" => {"type" => "table", "name" => "answers"},
+ "predicate" =>
+ {"type" => "eq",
+ "left_operand" => {"type" => "column", "table" => "answers", "name" => "question_id"},
+ "right_operand" => {"type" => "scalar", "value" => question.id}}},
+ "right_operand" => {"type" => "table", "name" => "users"},
+ "predicate" =>
+ {"type" => "eq",
+ "left_operand" => {"type" => "column", "table" => "answers", "name" => "creator_id"},
+ "right_operand" => {"type" => "column", "table" => "users", "name" => "id"}}}
+ ]
+
+ dataset = repository.fetch(*wire_reps)
+ dataset["users"].should have_key(user_1.to_param)
+ dataset["users"].should have_key(user_2.to_param)
+ dataset["answers"].should have_key(answer_1.to_param)
+ dataset["answers"].should have_key(answer_2.to_param)
+ end
+end
+
View
306 spec/models/question_spec.rb
@@ -0,0 +1,306 @@
+require 'spec_helper'
+
+module Models
+ describe Question do
+ attr_reader :question, :organization, :creator, :memphis, :knoxville, :chattanooga, :nashville, :unranked
+
+ before do
+ freeze_time
+
+ @organization = Organization.make
+ @creator = organization.make_member
+ @question = organization.questions.make(:body => "Where should the capital of Tennesee be?", :creator => creator)
+ @memphis = question.answers.make(:body => "Memphis")
+ @knoxville = question.answers.make(:body => "Knoxville")
+ @chattanooga = question.answers.make(:body => "Chattanooga")
+ @nashville = question.answers.make(:body => "Nashville")
+ @unranked = question.answers.make(:body => "Unranked")
+ end
+
+
+ describe ".update_scores" do
+ it "causes scores to go down as time passes" do
+ initial_score = question.score
+
+ question.update(:created_at => 1.hour.ago)
+ Question.update_scores
+
+ question.reload.score.should be < initial_score
+ end
+
+ it "causes scores to go up as votes are added" do
+ initial_score = question.score
+
+ question.update(:vote_count => 10)
+ Question.update_scores
+
+ question.reload.score.should be > initial_score
+ end
+ end
+
+ describe "before create" do
+ it "if the creator is not a member of the question's organization, makes them one (as long as the org is public)" do
+ set_current_user(User.make)
+ current_user.memberships.where(:organization => organization).should be_empty
+
+ organization.update(:privacy => "private")
+ expect do
+ organization.questions.create!(:body => "foo")
+ end.should raise_error(SecurityError)
+
+ organization.update(:privacy => "public")
+ organization.questions.create!(:body => "foo")
+
+ current_user.memberships.where(:organization => organization).size.should == 1
+ end
+
+ it "assigns the creator to the Model::Record.current_user" do
+ set_current_user(User.make)
+ question = Question.make
+ question.creator.should == current_user
+ end
+
+ it "assigns a score" do
+ question.score.should_not be_nil
+ end
+ end
+
+ describe "after create" do
+ attr_reader :organization, :creator, :opted_in, :opted_out, :non_member
+
+ before do
+ @organization = Organization.make
+ @creator = User.make
+ @opted_in = User.make
+ @opted_out = User.make
+ @non_member = User.make
+
+ organization.memberships.make(:user => creator, :notify_of_new_questions => "immediately")
+ organization.memberships.make(:user => opted_in, :notify_of_new_questions => "immediately")
+ organization.memberships.make(:user => opted_out, :notify_of_new_questions => "never")
+
+ set_current_user(creator)
+ end
+
+ it "enqueues a SendImmediateNotification job with the question" do
+ job_params = nil
+ mock(Jobs::SendImmediateNotifications).create(is_a(Hash)) do |params|
+ job_params = params
+ end
+
+ question = organization.questions.create!(:body => "What should we eat for dinner?")
+ job_params.should == { :class_name => "Question", :id => question.id }
+ end
+
+ it "increments the question count on its organization" do
+ lambda do
+ organization.questions.create!(:body => "What should we eat for dinner?")
+ end.should change { organization.question_count }.by(1)
+ end
+ end
+
+ describe "before update" do
+ it "updates the score if the vote count changed" do
+ score_before = question.score
+ question.vote_count += 1
+ question.save
+ question.score.should be > score_before
+ end
+ end
+
+ describe "before destroy" do
+ it "destroys any answers, answer comments, votes and visits that belong to the question" do
+ question = Question.make
+ user_1 = question.organization.make_member
+ user_2 = question.organization.make_member
+ answer_1 = question.answers.make
+ answer_2 = question.answers.make
+ answer_1.comments.make
+ answer_2.comments.make
+
+ Ranking.create!(:user => user_1, :answer => answer_1, :position => 64)
+ Ranking.create!(:user => user_1, :answer => answer_2, :position => 32)
+ Ranking.create!(:user => user_2, :answer => answer_1, :position => 64)
+ question.question_visits.create!(:user => user_1)
+
+ question.question_visits.size.should == 1
+ question.answers.size.should == 2
+ question.votes.size.should == 2
+ question.answers.join_through(AnswerComment).size.should == 2
+ question.destroy
+ question.answers.should be_empty
+ question.votes.should be_empty
+ question.question_visits.should be_empty
+ question.answers.join_through(AnswerComment).should be_empty
+ end
+ end
+
+ describe "after destroy" do
+ it "decrements the question count on its organization" do
+ question = Question.make
+ lambda do
+ question.destroy
+ end.should change { question.organization.question_count }.by(-1)
+ end
+ end
+
+ describe "#compute_global_ranking" do
+ it "uses the ranked-pairs algoritm to produce a global ranking, assigning a position of null to any unranked answers" do
+ jump(1.minute)
+
+ 4.times do
+ user = User.make
+ question.rankings.create(:user => user, :answer => memphis, :position => 4)
+ question.rankings.create(:user => user, :answer => nashville, :position => 3)
+ question.rankings.create(:user => user, :answer => chattanooga, :position => 2)
+ question.rankings.create(:user => user, :answer => knoxville, :position => 1)
+ end
+
+ 3.times do
+ user = User.make
+ question.rankings.create(:user => user, :answer => nashville, :position => 4)
+ question.rankings.create(:user => user, :answer => chattanooga, :position => 3)
+ question.rankings.create(:user => user, :answer => knoxville, :position => 2)
+ question.rankings.create(:user => user, :answer => memphis, :position => 1)
+ end
+
+ 1.times do
+ user = User.make
+ question.rankings.create(:user => user, :answer => chattanooga, :position => 4)
+ question.rankings.create(:user => user, :answer => knoxville, :position => 3)
+ question.rankings.create(:user => user, :answer => nashville, :position => 2)
+ question.rankings.create(:user => user, :answer => memphis, :position => 1)
+ end
+
+ 2.times do
+ user = User.make
+ question.rankings.create(:user => user, :answer => knoxville, :position => 4)
+ question.rankings.create(:user => user, :answer => chattanooga, :position => 3)
+ question.rankings.create(:user => user, :answer => nashville, :position => 2)
+ question.rankings.create(:user => user, :answer => memphis, :position => 1)
+ end
+
+ question.compute_global_ranking
+
+ nashville.reload.position.should == 1
+ chattanooga.position.should == 2
+ knoxville.position.should == 3
+ memphis.position.should == 4
+ unranked.position.should == 5
+
+ question.updated_at.to_i.should == Time.now.to_i
+ end
+ end
+
+ describe "#users_to_notify_immediately" do
+ it "includes members of the organization that have their question notification preference set to immediately and are not the creator of the question" do
+ notify1 = User.make
+ notify2 = User.make
+ dont_notify = User.make
+
+ organization.memberships.make(:user => notify1, :notify_of_new_questions => 'immediately')
+ organization.memberships.make(:user => notify2, :notify_of_new_questions => 'immediately')
+ organization.memberships.make(:user => dont_notify, :notify_of_new_questions => 'hourly')
+ organization.memberships.find(:user => creator).update!(:notify_of_new_questions => 'immediately')
+
+ question.users_to_notify_immediately.all.should =~ [notify1, notify2]
+ end
+ end
+
+ describe "security" do
+ attr_reader :organization, :member, :owner, :admin, :non_member
+
+ before do
+ @organization = Organization.make
+ @member = organization.make_member
+ @owner = organization.make_owner
+ @admin = User.make(:admin => true)
+ @non_member = User.make
+ end
+
+ describe "body length limit" do
+ it "raises a security error if trying to create or update with a body longer than 140 chars" do
+ long_body = "x" * 145
+
+ expect {
+ Question.make(:body => long_body)
+ }.to raise_error(SecurityError)
+
+ expect {
+ Question.make.update(:body => long_body)
+ }.to raise_error(SecurityError)
+
+ question = Question.make
+
+ # grandfathered questions can have other properties updated, but not the body
+ Prequel::DB[:questions].filter(:id => question.id).update(:body => long_body)
+ question.reload
+
+ question.update(:details => "Hi") # should work
+ expect {
+ question.update(:body => long_body + "and even longer!!!")
+ }.to raise_error(SecurityError)
+ end
+ end
+
+ describe "#can_create?" do
+ before do
+ @question = organization.questions.make_unsaved
+ end
+
+ context "if the question's organization is non-public" do
+ before do
+ question.organization.update(:privacy => "read_only")
+ end
+
+ specify "only members create answers" do
+ set_current_user(member)
+ question.can_create?.should be_true
+
+ set_current_user(non_member)
+ question.can_create?.should be_false
+ end
+ end
+
+ context "if the given question's organization is public" do
+ before do
+ question.organization.update(:privacy => "public")
+ end
+
+ specify "non-guest users can create answers" do
+ set_current_user(User.default_guest)
+ question.can_create?.should be_false
+
+ set_current_user(non_member)
+ question.can_create?.should be_true
+ end
+ end
+ end
+
+ describe "#can_update? and #can_destroy?" do
+ it "only allows admins, organization owners, and the creator of the question itself to update or destroy it" do
+ other_member = set_current_user(User.make)
+ organization.memberships.create!(:user => other_member)
+ question = organization.questions.create!(:body => "What should we do?")
+
+ set_current_user(member)
+ question.can_update?.should be_false
+ question.can_destroy?.should be_false
+
+
+ set_current_user(other_member)
+ question.can_update?.should be_true
+ question.can_destroy?.should be_true
+
+ set_current_user(owner)
+ question.can_update?.should be_true
+ question.can_destroy?.should be_true
+
+ set_current_user(admin)
+ question.can_update?.should be_true
+ question.can_destroy?.should be_true
+ end
+ end
+ end
+ end
+end
View
310 spec/models/ranking_spec.rb
@@ -0,0 +1,310 @@
+require 'spec_helper'
+
+describe Ranking do
+ describe "after create, update, or destroy" do
+ attr_reader :user, :question, :answer_1, :answer_2, :answer_3
+
+ before do
+ @user = User.make
+ @question = Question.make
+ @answer_1 = question.answers.make(:body => "1")
+ @answer_2 = question.answers.make(:body => "2")
+ @answer_3 = question.answers.make(:body => "3")
+ end
+
+ specify "majorities are updated accordingly and #compute_global_ranking is called on the ranking's question" do
+ question.majorities.each do |majority|
+ majority.pro_count.should == 0
+ end
+
+ # 1, (2, 3)
+ mock.proxy(question).compute_global_ranking
+ ranking_1 = question.rankings.create(:user => user, :answer => answer_1, :position => 64)
+ find_majority(answer_1, answer_2).pro_count.should == 1
+ find_majority(answer_1, answer_2).con_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_1).con_count.should == 1
+
+ find_majority(answer_1, answer_3).pro_count.should == 1
+ find_majority(answer_1, answer_3).con_count.should == 0
+ find_majority(answer_3, answer_1).pro_count.should == 0
+ find_majority(answer_3, answer_1).con_count.should == 1
+
+ # 1, 2, (3)
+ mock.proxy(question).compute_global_ranking
+ ranking_2 = question.rankings.create(:user => user, :answer => answer_2, :position => 32)
+ find_majority(answer_1, answer_2).pro_count.should == 1
+ find_majority(answer_1, answer_2).con_count.should == 0
+ find_majority(answer_1, answer_3).pro_count.should == 1
+ find_majority(answer_1, answer_3).con_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_1).con_count.should == 1
+ find_majority(answer_2, answer_3).pro_count.should == 1
+ find_majority(answer_2, answer_3).con_count.should == 0
+ find_majority(answer_3, answer_1).pro_count.should == 0
+ find_majority(answer_3, answer_1).con_count.should == 1
+ find_majority(answer_3, answer_2).pro_count.should == 0
+ find_majority(answer_3, answer_2).con_count.should == 1
+
+ # 1, 3, 2
+ mock.proxy(question).compute_global_ranking
+ ranking_3 = question.rankings.create(:user => user, :answer => answer_3, :position => 48)
+ find_majority(answer_1, answer_2).pro_count.should == 1
+ find_majority(answer_1, answer_2).con_count.should == 0
+ find_majority(answer_1, answer_3).pro_count.should == 1
+ find_majority(answer_1, answer_3).con_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_1).con_count.should == 1
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_3).con_count.should == 1
+ find_majority(answer_3, answer_1).pro_count.should == 0
+ find_majority(answer_3, answer_1).con_count.should == 1
+ find_majority(answer_3, answer_2).pro_count.should == 1
+ find_majority(answer_3, answer_2).con_count.should == 0
+
+ # 1, 2, 3
+ mock.proxy(question).compute_global_ranking
+ ranking_2.update(:position => 56)
+ find_majority(answer_1, answer_2).pro_count.should == 1
+ find_majority(answer_1, answer_2).con_count.should == 0
+ find_majority(answer_1, answer_3).pro_count.should == 1
+ find_majority(answer_1, answer_3).con_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_1).con_count.should == 1
+ find_majority(answer_2, answer_3).pro_count.should == 1
+ find_majority(answer_2, answer_3).con_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 0
+ find_majority(answer_3, answer_2).con_count.should == 1
+ find_majority(answer_3, answer_1).pro_count.should == 0
+ find_majority(answer_3, answer_1).con_count.should == 1
+
+ # 2, 1, 3
+ mock.proxy(question).compute_global_ranking
+ ranking_1.update(:position => 52)
+ find_majority(answer_1, answer_2).pro_count.should == 0
+ find_majority(answer_1, answer_2).con_count.should == 1
+ find_majority(answer_1, answer_3).pro_count.should == 1
+ find_majority(answer_1, answer_3).con_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 1
+ find_majority(answer_2, answer_1).con_count.should == 0
+ find_majority(answer_2, answer_3).pro_count.should == 1
+ find_majority(answer_2, answer_3).con_count.should == 0
+ find_majority(answer_3, answer_1).pro_count.should == 0
+ find_majority(answer_3, answer_1).con_count.should == 1
+ find_majority(answer_3, answer_2).pro_count.should == 0
+ find_majority(answer_3, answer_2).con_count.should == 1
+
+ # 3, 2, 1
+ mock.proxy(question).compute_global_ranking
+ ranking_3.update(:position => 128)
+ find_majority(answer_1, answer_2).pro_count.should == 0
+ find_majority(answer_1, answer_2).con_count.should == 1
+ find_majority(answer_1, answer_3).pro_count.should == 0
+ find_majority(answer_1, answer_3).con_count.should == 1
+ find_majority(answer_2, answer_1).pro_count.should == 1
+ find_majority(answer_2, answer_1).con_count.should == 0
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_3).con_count.should == 1
+ find_majority(answer_3, answer_1).pro_count.should == 1
+ find_majority(answer_3, answer_1).con_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 1
+ find_majority(answer_3, answer_2).con_count.should == 0
+
+ # 3, 1, (2)
+ mock.proxy(question).compute_global_ranking
+ ranking_2.destroy
+ find_majority(answer_1, answer_2).pro_count.should == 1
+ find_majority(answer_1, answer_2).con_count.should == 0
+ find_majority(answer_1, answer_3).pro_count.should == 0
+ find_majority(answer_1, answer_3).con_count.should == 1
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_1).con_count.should == 1
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_3).con_count.should == 1
+ find_majority(answer_3, answer_1).pro_count.should == 1
+ find_majority(answer_3, answer_1).con_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 1
+ find_majority(answer_3, answer_2).con_count.should == 0
+
+ # 1, (2, 3)
+ mock.proxy(question).compute_global_ranking
+ ranking_3.destroy
+ find_majority(answer_1, answer_2).pro_count.should == 1
+ find_majority(answer_1, answer_2).con_count.should == 0
+ find_majority(answer_1, answer_3).pro_count.should == 1
+ find_majority(answer_1, answer_3).con_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_1).con_count.should == 1
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_3).con_count.should == 0
+ find_majority(answer_3, answer_1).pro_count.should == 0
+ find_majority(answer_3, answer_1).con_count.should == 1
+ find_majority(answer_3, answer_2).pro_count.should == 0
+ find_majority(answer_3, answer_2).con_count.should == 0
+
+ mock.proxy(question).compute_global_ranking
+
+ ranking_1.destroy
+
+ question.majorities.each do |majority|
+ majority.reload
+ majority.pro_count.should == 0
+ majority.con_count.should == 0
+ end
+ end
+
+ specify "negatively ranked answers are counted as losing to unranked answers" do
+ question.majorities.each do |majority|
+ majority.pro_count.should == 0
+ end
+
+ # (2, 3), 1
+ mock.proxy(question).compute_global_ranking
+ ranking_1 = question.rankings.create(:user => user, :answer => answer_1, :position => -64)
+ find_majority(answer_1, answer_2).pro_count.should == 0
+ find_majority(answer_1, answer_2).con_count.should == 1
+ find_majority(answer_1, answer_3).pro_count.should == 0
+ find_majority(answer_1, answer_3).con_count.should == 1
+ find_majority(answer_2, answer_1).pro_count.should == 1
+ find_majority(answer_2, answer_1).con_count.should == 0
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_3).con_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 0
+ find_majority(answer_3, answer_2).con_count.should == 0
+ find_majority(answer_3, answer_1).pro_count.should == 1
+ find_majority(answer_3, answer_1).con_count.should == 0
+
+ # (3), 1, 2
+ mock.proxy(question).compute_global_ranking
+ ranking_2 = question.rankings.create(:user => user, :answer => answer_2, :position => -128)
+ find_majority(answer_1, answer_2).pro_count.should == 1
+ find_majority(answer_1, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 1
+ find_majority(answer_3, answer_1).pro_count.should == 1
+
+ # (3), 2, 1
+ mock.proxy(question).compute_global_ranking
+ ranking_2.update(:position => -32)
+ find_majority(answer_1, answer_2).pro_count.should == 0
+ find_majority(answer_1, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 1
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 1
+ find_majority(answer_3, answer_1).pro_count.should == 1
+
+ # 1, (3), 2
+ mock.proxy(question).compute_global_ranking
+ ranking_1.update(:position => 64)
+ find_majority(answer_1, answer_2).pro_count.should == 1
+ find_majority(answer_1, answer_3).pro_count.should == 1
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 1
+ find_majority(answer_3, answer_1).pro_count.should == 0
+
+ # (3), 1, 2
+ mock.proxy(question).compute_global_ranking
+ ranking_1.update(:position => -16)
+ find_majority(answer_1, answer_2).pro_count.should == 1
+ find_majority(answer_1, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 1
+ find_majority(answer_3, answer_1).pro_count.should == 1
+
+ # (3, 2), 1
+ mock.proxy(question).compute_global_ranking
+ ranking_2.destroy
+ find_majority(answer_1, answer_2).pro_count.should == 0
+ find_majority(answer_1, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 1
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 0
+ find_majority(answer_3, answer_1).pro_count.should == 1
+
+ # (1, 2, 3) -- all unranked
+ mock.proxy(question).compute_global_ranking
+ ranking_1.destroy
+ find_majority(answer_1, answer_2).pro_count.should == 0
+ find_majority(answer_1, answer_3).pro_count.should == 0
+ find_majority(answer_2, answer_1).pro_count.should == 0
+ find_majority(answer_2, answer_3).pro_count.should == 0
+ find_majority(answer_3, answer_2).pro_count.should == 0
+ find_majority(answer_3, answer_1).pro_count.should == 0
+ end
+
+ specify "it creates, updates, or destroys the associated vote as appropriate" do
+ freeze_time
+ users_votes = question.votes.where(:user => user)
+
+ users_votes.should be_empty
+
+ ranking_1 = question.rankings.create(:user => user, :answer => answer_1, :position => 64)
+
+ users_votes.size.should == 1
+ vote = question.votes.find(:user => user)
+ ranking_1.vote.should == vote
+ vote.created_at.should == Time.now
+ vote.updated_at.should == Time.now
+
+ jump(1.minute)
+
+ ranking_2 = question.rankings.create(:user => user, :answer => answer_2, :position => 32)
+ users_votes.size.should == 1
+ ranking_2.vote.should == vote
+ vote.updated_at.should == Time.now
+
+ jump(1.minute)
+
+ ranking_1.update(:position => 16)
+ users_votes.size.should == 1
+ vote.updated_at.should == Time.now
+
+ jump(1.minute)
+
+ ranking_2.destroy
+ users_votes.size.should == 1
+ vote.updated_at.should == Time.now
+
+ ranking_1.destroy
+ users_votes.should be_empty
+ end
+ end
+
+ describe "security" do
+ attr_reader :creator, :other_member, :ranking, :answer
+ before do
+ question = Question.make
+ @answer = question.answers.make
+ @creator = answer.question.organization.make_member
+ @other_member = answer.question.organization.make_member
+ @ranking = Ranking.create!(:user => creator, :answer => answer, :position => 64)
+ end
+
+ describe "#can_create? and #can_update?" do
+ it "does not allow anyone to create or update, because that is done through a custom action" do
+ new_ranking = Ranking.new(:user => creator, :answer => answer, :position => 64)
+
+ set_current_user(other_member)
+ new_ranking.can_create?.should be_false
+ ranking.can_update?.should be_false
+
+ set_current_user(creator)
+ new_ranking.can_create?.should be_false
+ ranking.can_update?.should be_false
+ end
+ end
+
+ describe "#can_destroy?" do
+ it "only allows the user who created the ranking to destroy it" do
+ set_current_user(other_member)
+ ranking.can_destroy?.should be_false
+
+ set_current_user(creator)
+ ranking.can_destroy?.should be_true
+ end
+ end
+ end
+end
View
242 spec/models/user_spec.rb
@@ -0,0 +1,242 @@
+require 'spec_helper'
+
+module Models
+ describe User do
+ let(:user) { User.make }
+
+ describe ".default_guest" do
+ it "returns the default guest" do
+ User.default_guest.should be_default_guest
+ end
+ end
+
+ describe "#validate" do
+ it "ensures first_name, last_name, email_address, and encrypted_password are present" do
+ User.make_unsaved(:first_name => "").should_not be_valid
+ User.make_unsaved(:last_name => "").should_not be_valid
+ User.make_unsaved(:email_address => "").should_not be_valid
+ User.make_unsaved(:password => "").should_not be_valid
+ end
+
+ it "ensures the email address is unique" do
+ user.should be_valid
+ User.make_unsaved(:email_address => user.email_address).should_not be_valid
+ end
+
+ it "allows there to be no encrypted_password if there is a facebook_id" do
+ User.make_unsaved(:password => "", :facebook_id => "uid").should be_valid
+ end
+
+ it "allows there to be no encrypted_password, last_name, or email_address if there is a twitter_id" do
+ User.make_unsaved(:password => nil, :last_name => nil, :email_address => nil, :twitter_id => 1969).should be_valid
+ end
+ end
+
+ describe "#after_create" do
+ it "if on production, sends admin an email about the new user" do
+ user = expect_delivery { User.make }
+ last_delivery.to.map(&:to_s).should =~ ["max@hyperarchy.com", "nathan@hyperarchy.com"]
+ last_delivery.body.should include(user.full_name)
+ last_delivery.body.should include(user.email_address)
+ end
+
+ it "makes the new user a member of social" do
+ user = User.make
+ user.organizations.all.should == [Organization.social]
+ user.memberships.first.tap do |m|
+ m.role.should == 'member'
+ end
+ end
+ end
+
+ describe "#password and #password=" do
+ specify "#password= assigns #encrypted_password such that #password returns a BCrypt::Password object that will be == to the assigned unencrypted password" do
+ user.password = "password"
+ user.encrypted_password.should_not be_nil
+ user.password.should == "password"
+ user.password.should_not == "foo"
+ end
+ end
+
+ describe "#generate_password_reset_token" do
+ it "sets the password reset token to a random string and also sets the password reset timestamp" do
+ freeze_time
+
+ user.password_reset_token.should be_nil
+ user.password_reset_token_generated_at.should be_nil
+ user.generate_password_reset_token
+ user.password_reset_token.should_not be_nil
+ user.password_reset_token_generated_at.should == Time.now
+ end
+ end
+
+ describe "#initial_repository_contents" do
+ attr_reader :org_1, :org_2, :org_3
+
+ before do
+ @org_1 = Organization.make(:privacy => "public")
+ @org_2 = Organization.make(:privacy => "read_only")
+ @org_3 = Organization.make(:privacy => "private")
+ end
+
+ context "if the user is a member" do
+ attr_reader :member, :contents, :org_4, :membership_1, :membership_2
+
+ before do
+ @org_4 = Organization.make(:privacy => "private")
+
+ @member = User.make
+ @membership_1 = member.memberships.make(:organization => org_1)
+ @membership_2 = member.memberships.make(:organization => org_3)
+ @contents = member.initial_repository_contents
+ end
+
+ it "includes the member's user model, their memberships, all non-private organizations, and all private organizations that the user is a member of" do
+ contents.should include(member)
+ contents.should include(membership_1)
+ contents.should include(membership_2)
+ contents.should include(org_1)
+ contents.should include(org_2)
+ contents.should include(org_3)
+ contents.should_not include(org_4)
+ end
+ end
+
+ context "if the user is an admin" do
+ attr_reader :admin, :contents, :membership
+
+ before do
+ @admin = User.make(:admin => true)
+ @membership = admin.memberships.make(:organization => org_1)
+ @contents = admin.initial_repository_contents
+ end
+
+ it "includes the admin's user model, memberships, and all organizations" do
+ contents.should include(admin)
+ contents.should include(membership)
+ contents.should include(org_1)
+ contents.should include(org_2)
+ contents.should include(org_3)
+ end
+ end
+ end
+
+ describe "#default_organization" do
+ context "when the user has memberships" do
+ it "returns their last visited organization" do
+ organization_1 = Organization.make
+ organization_2 = Organization.make
+ user = User.make
+ user.memberships.make(:organization => organization_1, :created_at => 3.hours.ago)
+ user.memberships.make(:organization => organization_2, :created_at => 1.hour.ago)
+
+ user.default_organization.should == organization_2
+ end
+ end
+ end
+
+ describe "#guest_organization" do
+ context "if the user is a special guest" do
+ it "returns the organization they are a guest of" do
+ org = Organization.make
+ org.guest.guest_organization.should == org
+ end
+ end
+
+ context "if the user is the default guest" do
+ it "returns nil" do
+ User.default_guest.guest_organization.should be_nil
+ end
+ end
+
+ context "if the user is a normal user" do
+ it "returns nil" do
+ User.make.guest_organization.should be_nil
+ end
+ end
+ end
+
+ describe "#email_hash" do
+ it "returns nil if email_address is nil" do
+ user.update!(:twitter_id => 123, :email_address => nil)
+ user.email_hash.should be_nil
+ end
+ end
+
+ describe "methods supporting notifications" do
+ describe ".users_to_notify(period)" do
+ it "returns those non-guest users who have at least one membership with a notification preference matching the job's period and who have emails enabled" do
+ m1 = make_membership('hourly', 'never', 'never', 'never')
+ m2 = make_membership('never', 'hourly', 'never', 'never')
+ m3 = make_membership('never', 'never', 'hourly', 'never')
+ m4 = make_membership('never', 'never', 'never', 'hourly')
+ m4b = make_membership('never', 'never', 'never', 'hourly', :user => m4.user) # ensure distinct users
+ guest_m = make_membership('hourly', 'never', 'never', 'never')
+ guest_m.user.update!(:guest => true)
+ disabled_m = make_membership('hourly', 'never', 'never', 'never')
+ disabled_m.user.update!(:email_enabled => false)
+
+ make_membership('never', 'never', 'never', 'never')
+
+ User.users_to_notify('hourly').all.map(&:id).should =~ [m1, m2, m3, m4].map(&:user).map(&:id)
+ end
+
+ def make_membership(questions, answers, comments_on_ranked, comments_on_own, additional_attrs = {})
+ Membership.make(additional_attrs.merge(
+ :notify_of_new_questions => questions,
+ :notify_of_new_answers => answers,
+ :notify_of_new_comments_on_own_answers => comments_on_ranked,
+ :notify_of_new_comments_on_ranked_answers => comments_on_own
+ ))
+ end
+
+ describe "#memberships_to_notify(period)" do
+ it "returns those memberships with at least one notification preference set to the given period, with social memberships first" do
+ social_membership = user.memberships.first
+ social_membership.update(:notify_of_new_answers => 'hourly')
+
+ m1 = make_membership('hourly', 'never', 'never', 'never')
+ m2 = make_membership('never', 'hourly', 'never', 'never')
+ m3 = make_membership('never', 'never', 'hourly', 'never')
+ m4 = make_membership('never', 'never', 'never', 'hourly')
+ m5 = make_membership('never', 'never', 'never', 'never')
+
+ m1.update!(:user => user)
+ m2.update!(:user => user)
+ m3.update!(:user => user)
+ m4.update!(:user => user)
+ m5.update!(:user => user)
+
+ memberships = user.memberships_to_notify("hourly")
+ memberships.should =~ [social_membership, m1, m2, m3, m4]
+ memberships.first.should == social_membership
+ end
+ end
+ end
+ end
+
+ describe "security" do
+ describe "#can_update? and #can_destroy?" do
+ it "only allows admins and the users themselves to update / destroy user records, and only allows admins to set the admin flag" do
+ user = User.make
+ admin = User.make(:admin => true)
+ other_user = User.make
+
+ set_current_user(other_user)
+ user.can_update?.should be_false
+ user.can_destroy?.should be_false
+
+ set_current_user(admin)
+ user.can_update?.should be_true
+ user.can_destroy?.should be_true
+ user.can_update_columns?([:admin]).should be_true
+
+ set_current_user(user)
+ user.can_update?.should be_true
+ user.can_destroy?.should be_true
+ user.can_update_columns?([:admin]).should be_false
+ end
+ end
+ end
+ end
+end
View
32 spec/models/vote_spec.rb
@@ -0,0 +1,32 @@
+require 'spec_helper'
+
+describe Vote do
+ describe "after create or destroy" do
+ attr_reader :user_1, :user_2, :question, :answer
+
+ before do
+ @user_1 = User.make
+ @user_2 = User.make
+ @question = Question.make
+ @answer = question.answers.make
+ end
+
+ specify "the vote count of the question is increment or decrement appropriately" do
+ question.vote_count.should == 0
+ ranking_1 = question.rankings.create(:user => user_1, :answer => answer, :position => 64)
+ vote_1 = ranking_1.vote
+ vote_1.should_not be_nil
+ vote_1.question.should == question
+ question.vote_count.should == 1
+
+ ranking_2 = question.rankings.create(:user => user_2, :answer => answer, :position => 64)
+ vote_2 = ranking_2.vote
+ question.vote_count.should == 2
+
+ vote_1.destroy
+ question.vote_count.should == 1
+ vote_2.destroy
+ question.vote_count.should == 0
+ end
+ end
+end
View
17 spec/spec_helper.rb
@@ -8,14 +8,19 @@
Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f}
RSpec.configure do |config|
+ config.treat_symbols_as_metadata_keys_with_true_values = true
+ config.include SpecMethods
+ config.include ModelSpecMethods, :type => :model
+ config.include ControllerSpecMethods, :type => :controller
+
# == Mock Framework
#
# If you prefer to use mocha, flexmock or RR, uncomment the appropriate line:
#
# config.mock_with :mocha
# config.mock_with :flexmock
- # config.mock_with :rr
- config.mock_with :rspec
+ config.mock_with :rr
+ # config.mock_with :rspec
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_path = "#{::Rails.root}/spec/fixtures"
@@ -25,3 +30,11 @@
# instead of true.
config.use_transactional_fixtures = true
end
+
+def fit(description, &block)
+ it description, :focus, &block
+end
+
+def fdescribe(description, &block)
+ describe description, :focus, &block
+end
View
21 spec/support/blueprints.rb
@@ -0,0 +1,21 @@
+require "prequel/machinist_adaptor"
+
+User.blueprint do
+ full_name { Faker::Name.name }
+ email_address { Faker::Internet.email }
+end
+
+Question.blueprint do
+ body { Faker::Lorem.sentence.chop + "?" }
+end
+
+Answer.blueprint do
+ question_id { Question.make!.id }
+ body { Faker::Lorem.sentence.chop }
+end
+
+Ranking.blueprint do
+end
+
+Vote.blueprint do
+end
View
51 spec/support/controller_spec_methods.rb
@@ -0,0 +1,51 @@
+module ControllerSpecMethods
+ def login_as(user)
+ session[:current_user_id] = user.id
+ user
+ end
+
+ def logout
+ session[:current_user_id] = nil
+ end
+
+ def current_user_id
+ controller.send(:current_user_id)
+ end
+
+ def current_user
+ controller.send(:current_user)
+ end
+
+ def response_json
+ @response_json ||= ActiveSupport::JSON.decode(response.body)
+ end
+
+ def response_records
+ if response_json['records']
+ records = response_json['records']
+ else
+ records = response_json
+ end
+
+ @response_records ||= RecordsWrapper.new(records)
+ end
+
+ class RecordsWrapper
+ attr_reader :records_hash
+
+ def initialize(records_hash)
+ raise "There are no records in the response" unless records_hash
+ @records_hash = records_hash
+ end
+
+ def include?(*records_or_relations)
+ Array(records_or_relations).flatten.each do |record|
+ table_name = record.table.name.to_s
+ table_hash = records_hash[table_name]
+ raise "Response records do not contain #{table_name} key" unless table_hash
+ table_hash[record.to_param].should == record.wire_representation
+ end
+ true
+ end
+ end
+end
View
13 spec/support/model_spec_methods.rb
@@ -0,0 +1,13 @@
+module ModelSpecMethods
+ def set_current_user(user)
+ Prequel.session.current_user = user
+ end
+
+ def current_user
+ Prequel.session.current_user
+ end
+
+ def find_majority(winner, loser)
+ question.majorities.find(:winner => winner, :loser => loser).reload
+ end
+end
View
37 spec/support/spec_methods.rb
@@ -0,0 +1,37 @@
+module SpecMethods
+ def time_travel_to(time)
+ stub(Time).now { time }
+ time
+ end
+
+ def freeze_time
+ time_travel_to(Time.now)
+ end
+
+ def jump(period)
+ time_travel_to(Time.now + period)
+ end
+
+ def with_rails_env(env)
+ previous, Rails.env = Rails.env, env
+ yield
+ ensure
+ Rails.env = previous
+ end
+
+ def clear_deliveries
+ ActionMailer::Base.deliveries.clear
+ end
+
+ def last_delivery
+ ActionMailer::Base.deliveries.last
+ end
+
+ def expect_delivery(&block)
+ result = nil
+ expect do
+ result = block.call
+ end.to change(ActionMailer::Base.deliveries, :length).by(1)
+ result
+ end
+end
2  vendor/prequel
@@ -1 +1 @@
-Subproject commit 6323be42e74937903637ecee9ccdded80ed491f6
+Subproject commit 407d193ddb218813eba18bf4b61399831844f8ce
Please sign in to comment.
Something went wrong with that request. Please try again.