├── .gitignore ├── .ruby-version ├── .travis.yml ├── Gemfile ├── MIT-LICENSE ├── README.markdown ├── app └── models │ └── punch.rb ├── lib ├── generators │ └── punching_bag │ │ ├── punching_bag_generator.rb │ │ └── templates │ │ └── create_punches_table.rb ├── punching_bag.rb ├── punching_bag │ ├── acts_as_punchable.rb │ ├── acts_as_taggable_on.rb │ ├── engine.rb │ └── version.rb └── tasks │ └── punching_bag.rake ├── punching_bag.gemspec └── spec ├── internal ├── app │ └── models │ │ └── article.rb ├── config │ └── database.yml └── db │ ├── combustion_test.sqlite │ └── schema.rb ├── lib ├── punching_bag │ └── acts_as_punchable_spec.rb └── punching_bag_spec.rb ├── models ├── punch_spec.rb └── punchable_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *~ 3 | *.gem 4 | Gemfile.lock 5 | /spec/internal/log/* 6 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.5 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | nguage: ruby 2 | rvm: 3 | - 2.4.6 4 | - 2.5.5 5 | - 2.6.3 6 | script: bundle exec rspec 7 | before_install: 8 | # fixes Travis CI error: NoMethodError: undefined method `spec' for nil:NilClass 9 | - gem install bundler 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 by Biola University 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Punching Bag [![Build Status](https://travis-ci.org/biola/punching_bag.png?branch=master)](https://travis-ci.org/biola/punching_bag) 2 | ============ 3 | Punching Bag is a hit tracking plugin for Ruby on Rails that specializes in simple trending. 4 | 5 | Features 6 | ======== 7 | * Total hit count 8 | * Hit counts for the last day, week, month, etc. 9 | * Simple trending based on most hits in the last day, week, month, etc. 10 | * Rake task to group old hit records for better performance 11 | * [ActsAsTaggableOn](https://github.com/mbleigh/acts-as-taggable-on) integration for trending tags/topics support 12 | * [Voight-Kampff](https://github.com/biola/Voight-Kampff) integration for bot checking 13 | 14 | Requirements 15 | ============ 16 | 17 | - An existing Rails app 18 | - Ruby >= 2.3 19 | 20 | Installation 21 | ============ 22 | 23 | __In your Gemfile add:__ 24 | 25 | gem "punching_bag" 26 | 27 | __In the terminal run:__ 28 | 29 | bundle install 30 | rails g punching_bag 31 | rake db:migrate 32 | 33 | __In your model add:__ 34 | 35 | acts_as_punchable 36 | 37 | Usage 38 | ===== 39 | __Tracking hits in your controller__ 40 | 41 | class PostsController < ApplicationController 42 | def show 43 | @post.punch(request) 44 | end 45 | end 46 | 47 | __Getting a total hit count in your view__ 48 | 49 | @post.hits 50 | 51 | __Getting a hit count for a time period in your view__ 52 | 53 | @post.hits(1.week.ago) 54 | 55 | __Getting a list of the five all-time most hit posts__ 56 | 57 | Post.most_hit 58 | 59 | __Getting a list of the 10 most hit posts for the last 24 hours__ 60 | 61 | Post.most_hit(1.day.ago, 10) # limit is 5 by default, pass nil for no limits 62 | 63 | __Sorting posts based on all time hit count__ 64 | 65 | Post.sort_by_popularity('DESC') # DESC by default, can also use ASC 66 | 67 | __Getting a hit count on a tag for the last month__ 68 | 69 | tag.hits(1.month.ago) 70 | 71 | __Getting a list of the 10 most hit tags in the last week__ 72 | 73 | ActsAsTaggableOn::Tag.most_hit(1.month.ago, 10) 74 | 75 | __Compressing old hit records to improve performance__ 76 | *The default settings combine records by day if they're older than 7 days, by month if they're older than 1 month and by year if they're older than 1 year* 77 | 78 | rake punching_bag:combine 79 | 80 | __Compressing old hit records using custom settings__ 81 | *This time we'll combine records by day if they're older than 14 days, by month if they're older than 3 months and by year if they're older than 2 years* 82 | 83 | rake punching_bag:combine[14,3,2] 84 | 85 | Notes 86 | ===== 87 | * The `punching_bag:combine` rake tasks is not run automatically. You'll have to run it manually or add it as a cron job. 88 | * The `punching_bag:combine` rake task can take a while depending on how many records need to be combined. 89 | * Passing the `request` object to the `punch` method is optional but without it requests from bots, crawlers and spiders will be tracked. 90 | * See the [Voight-Kampff](https://github.com/biola/Voight-Kampff) documentation if you'd like to customize the list of user-agents considered bots. 91 | * The tag related features will only work if you have [ActsAsTaggableOn](https://github.com/mbleigh/acts-as-taggable-on) installed and enabled on the same models as Punching Bag. 92 | -------------------------------------------------------------------------------- /app/models/punch.rb: -------------------------------------------------------------------------------- 1 | class Punch < ActiveRecord::Base 2 | 3 | belongs_to :punchable, :polymorphic => true 4 | 5 | before_validation :set_defaults 6 | validates :punchable_id, :punchable_type, :starts_at, :ends_at, :average_time, :hits, :presence => true 7 | 8 | default_scope -> { order 'punches.average_time DESC' } 9 | scope :combos, -> { where 'punches.hits > 1' } 10 | scope :jabs, -> { where hits: 1 } 11 | scope :before, ->(time = nil) { where('punches.ends_at <= ?', time) unless time.nil? } 12 | scope :after, ->(time = nil) { where('punches.average_time >= ?', time) unless time.nil? } 13 | scope :by_timeframe, ->(timeframe, time) { 14 | where('punches.starts_at >= ? AND punches.ends_at <= ?', time.send("beginning_of_#{timeframe}"), time.send("end_of_#{timeframe}")) 15 | } 16 | scope :by_hour, ->(hour) { by_timeframe :hour, hour } 17 | scope :by_day, ->(day) { by_timeframe :day, day } 18 | scope :by_month, ->(month) { by_timeframe :month, month } 19 | scope :by_year, ->(year) { 20 | year = DateTime.new(year) if year.is_a? Integer 21 | by_timeframe :year, year 22 | } 23 | scope :except_for, ->(punch) { where('id != ?', punch.id) } 24 | 25 | def jab? 26 | hits == 1 27 | end 28 | 29 | def combo? 30 | hits > 1 31 | end 32 | 33 | def timeframe 34 | if starts_at.month != ends_at.month 35 | :year 36 | elsif starts_at.day != ends_at.day 37 | :month 38 | elsif starts_at.hour != ends_at.hour 39 | :day 40 | elsif starts_at != ends_at 41 | :hour 42 | else 43 | :second 44 | end 45 | end 46 | 47 | def hour_combo? 48 | timeframe == :hour and not find_true_combo_for(:hour) == self 49 | end 50 | 51 | def day_combo? 52 | timeframe == :day and not find_true_combo_for(:day) == self 53 | end 54 | 55 | def month_combo? 56 | timeframe == :month and not find_true_combo_for(:month) == self 57 | end 58 | 59 | def year_combo? 60 | timeframe == :year and not find_true_combo_for(:year) == self 61 | end 62 | 63 | def find_combo_for(timeframe) 64 | punches = punchable.punches.by_timeframe(timeframe, average_time).except_for(self) 65 | punches.combos.first || punches.first 66 | end 67 | 68 | def find_true_combo_for(timeframe) 69 | punchable.punches.combos.by_timeframe(timeframe, average_time).first 70 | end 71 | 72 | def combine_with(combo) 73 | if combo && combo != self 74 | combo.starts_at = starts_at if starts_at < combo.starts_at 75 | combo.ends_at = ends_at if ends_at > combo.ends_at 76 | combo.average_time = PunchingBag.average_time(combo, self) 77 | combo.hits += hits 78 | self.destroy if combo.save 79 | end 80 | combo 81 | end 82 | 83 | def combine_by_hour 84 | unless hour_combo? || day_combo? || month_combo? || year_combo? 85 | combine_with find_combo_for(:hour) 86 | end 87 | end 88 | 89 | def combine_by_day 90 | unless day_combo? || month_combo? || year_combo? 91 | combine_with find_combo_for(:day) 92 | end 93 | end 94 | 95 | def combine_by_month 96 | unless month_combo? || year_combo? 97 | combine_with find_combo_for(:month) 98 | end 99 | end 100 | 101 | def combine_by_year 102 | unless year_combo? 103 | combine_with find_combo_for(:year) 104 | end 105 | end 106 | 107 | def self.average_for(punchables) 108 | if punchables.map(&:class).uniq.length > 1 109 | raise ArgumentError, 'Punchables must all be of the same class' 110 | end 111 | 112 | sums = Punch.where(punchable_type: punchables.first.class.to_s, punchable_id: punchables.map(&:id)).group(:punchable_id).sum(:hits) 113 | 114 | return 0 if sums.empty? # catch divide by zero 115 | 116 | sums.values.inject(:+).to_f / sums.length 117 | end 118 | 119 | private 120 | 121 | def set_defaults 122 | if date = (self.starts_at ||= DateTime.now) 123 | self.ends_at ||= date 124 | self.average_time ||= date 125 | self.hits ||= 1 126 | end 127 | end 128 | 129 | end 130 | -------------------------------------------------------------------------------- /lib/generators/punching_bag/punching_bag_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/migration' 3 | 4 | class PunchingBagGenerator < Rails::Generators::Base 5 | include Rails::Generators::Migration 6 | source_root File.join(File.dirname(__FILE__), 'templates') 7 | 8 | def self.next_migration_number(dirname) 9 | sleep 1 10 | if ActiveRecord::Base.timestamped_migrations 11 | Time.now.utc.strftime("%Y%m%d%H%M%S") 12 | else 13 | "%.3d" % (current_migration_number(dirname) + 1) 14 | end 15 | end 16 | 17 | def create_migration_file 18 | migration_template 'create_punches_table.rb', 'db/migrate/create_punches_table.rb' 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/generators/punching_bag/templates/create_punches_table.rb: -------------------------------------------------------------------------------- 1 | class CreatePunchesTable < ActiveRecord::Migration[4.2] 2 | def self.up 3 | create_table :punches do |t| 4 | t.integer :punchable_id, :null => false 5 | t.string :punchable_type, :null => false, :limit => 20 6 | t.datetime :starts_at, :null => false 7 | t.datetime :ends_at, :null => false 8 | t.datetime :average_time, :null => false 9 | t.integer :hits, :null => false, :default=>1 10 | end 11 | add_index :punches, [:punchable_type, :punchable_id], :name => :punchable_index, :unique => false 12 | add_index :punches, :average_time, :unique => false 13 | end 14 | 15 | def self.down 16 | remove_index :punches, :name => :punchable_index 17 | remove_index :punches, :average_time 18 | drop_table :punches 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/punching_bag.rb: -------------------------------------------------------------------------------- 1 | module PunchingBag 2 | require 'punching_bag/engine' if defined?(Rails) 3 | require 'punching_bag/acts_as_punchable' 4 | require 'voight_kampff' 5 | 6 | def self.punch(punchable, request = nil, count = 1) 7 | if request.try(:bot?) 8 | false 9 | else 10 | p = Punch.new 11 | p.punchable = punchable 12 | p.hits = count 13 | p.save ? p : false 14 | end 15 | end 16 | 17 | def self.average_time(*punches) 18 | total_time = 0 19 | hits = 0 20 | punches.each do |punch| 21 | total_time += punch.average_time.to_f * punch.hits 22 | hits += punch.hits 23 | end 24 | Time.zone.at(total_time / hits) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/punching_bag/acts_as_punchable.rb: -------------------------------------------------------------------------------- 1 | module PunchingBag 2 | module ActiveRecord 3 | module ClassMethods 4 | DIRECTIONS = { 5 | asc: 'ASC', 6 | desc: 'DESC' 7 | }.freeze 8 | DEFAULT_DIRECTION = :desc 9 | 10 | # Note: this method will only return items if they have 1 or more hits 11 | def most_hit(since = nil, limit = 5) 12 | query = joins(:punches).group(Punch.arel_table[:punchable_type], Punch.arel_table[:punchable_id], arel_table[primary_key]) 13 | query = query.where('punches.average_time >= ?', since) unless since.nil? 14 | query.reorder(Arel.sql('SUM(punches.hits) DESC')).limit(limit) 15 | end 16 | 17 | # Note: this method will return all items with 0 or more hits 18 | # direction: Symbol (:asc, or :desc) 19 | def sort_by_popularity(direction = DEFAULT_DIRECTION) 20 | dir = DIRECTIONS.fetch( 21 | direction.to_s.downcase.to_sym, 22 | DIRECTIONS[DEFAULT_DIRECTION] 23 | ) 24 | 25 | query = joins( 26 | arel_table.join( 27 | Punch.arel_table, Arel::Nodes::OuterJoin 28 | ).on( 29 | Punch.arel_table[:punchable_id].eq( 30 | arel_table[primary_key] 31 | ).and( 32 | Punch.arel_table[:punchable_type].eq(name) 33 | ) 34 | ).join_sources.first 35 | ) 36 | 37 | query = query.group(arel_table[primary_key]) 38 | query.reorder(Arel.sql("SUM(punches.hits) #{dir}")) 39 | end 40 | end 41 | 42 | module InstanceMethods 43 | def hits(since = nil) 44 | punches.after(since).sum(:hits) 45 | end 46 | 47 | def punch(request = nil, options = {}) 48 | count = options[:count] || 1 49 | PunchingBag.punch(self, request, count) 50 | end 51 | end 52 | end 53 | end 54 | 55 | class ActiveRecord::Base 56 | def self.acts_as_punchable 57 | extend PunchingBag::ActiveRecord::ClassMethods 58 | include PunchingBag::ActiveRecord::InstanceMethods 59 | has_many :punches, as: :punchable, dependent: :destroy 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/punching_bag/acts_as_taggable_on.rb: -------------------------------------------------------------------------------- 1 | # Adds methods to enable tracking tags through a common polymorphic association 2 | module ActsAsTaggableOn 3 | class Tag 4 | def self.most_hit(since=nil, limit=5) 5 | query = Tagging.scoped. 6 | joins('INNER JOIN punches ON (taggings.taggable_id = punches.punchable_id AND taggings.taggable_type = punches.punchable_type)'). 7 | group(:tag_id). 8 | order('SUM(punches.hits) DESC'). 9 | limit(limit) 10 | query = query.where('punches.average_time >= ?', since) if since 11 | query.map(&:tag) 12 | end 13 | 14 | def hits(since=nil) 15 | query = Tagging.scoped. 16 | joins('INNER JOIN punches ON (taggings.taggable_id = punches.punchable_id AND taggings.taggable_type = punches.punchable_type)'). 17 | where(:tag_id => self.id) 18 | query = query.where('punches.average_time >= ?', since) if since 19 | query.sum('punches.hits') 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/punching_bag/engine.rb: -------------------------------------------------------------------------------- 1 | require 'punching_bag' 2 | require 'rails' 3 | require 'active_record' 4 | 5 | module PunchingBag 6 | class Engine < Rails::Engine 7 | initializer 'punching_bag.extend_acts_as_taggable_on' do 8 | require 'punching_bag/acts_as_taggable_on' if defined? ActsAsTaggableOn 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/punching_bag/version.rb: -------------------------------------------------------------------------------- 1 | module PunchingBag 2 | VERSION = '0.6.1' 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/punching_bag.rake: -------------------------------------------------------------------------------- 1 | namespace :punching_bag do 2 | desc 'Combine old hit records together to improve performance' 3 | task( 4 | :combine, 5 | %i[by_hour_after by_day_after by_month_after by_year_after] => [:environment] 6 | ) do |_t, args| 7 | args.with_defaults( 8 | by_hour_after: 24, 9 | by_day_after: 7, 10 | by_month_after: 1, 11 | by_year_after: 1 12 | ) 13 | 14 | distinct_method = Rails.version >= '5.0' ? :distinct : :uniq 15 | 16 | punchable_types = Punch.unscope(:order).public_send( 17 | distinct_method 18 | ).pluck(:punchable_type) 19 | 20 | punchable_types.each do |punchable_type| 21 | punchables = punchable_type.constantize.unscoped.find( 22 | Punch.unscope(:order).public_send(distinct_method).where( 23 | punchable_type: punchable_type 24 | ).pluck(:punchable_id) 25 | ) 26 | 27 | punchables.each do |punchable| 28 | # by_year 29 | punchable.punches.before( 30 | args[:by_year_after].to_i.years.ago 31 | ).each do |punch| 32 | # Dont use the cached version. 33 | # We might have changed if we were the combo 34 | punch.reload 35 | punch.combine_by_year 36 | end 37 | 38 | # by_month 39 | punchable.punches.before( 40 | args[:by_month_after].to_i.months.ago 41 | ).each do |punch| 42 | # Dont use the cached version. 43 | # We might have changed if we were the combo 44 | punch.reload 45 | punch.combine_by_month 46 | end 47 | 48 | # by_day 49 | punchable.punches.before( 50 | args[:by_day_after].to_i.days.ago 51 | ).each do |punch| 52 | # Dont use the cached version. 53 | # We might have changed if we were the combo 54 | punch.reload 55 | punch.combine_by_day 56 | end 57 | 58 | # by_hour 59 | punchable.punches.before( 60 | args[:by_hour_after].to_i.hours.ago 61 | ).each do |punch| 62 | # Dont use the cached version. 63 | # We might have changed if we were the combo 64 | punch.reload 65 | punch.combine_by_hour 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /punching_bag.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib/', __FILE__) 2 | $:.unshift lib unless $:.include?(lib) 3 | 4 | require 'punching_bag/version' 5 | 6 | spec = Gem::Specification.new do |s| 7 | s.name = 'punching_bag' 8 | s.version = PunchingBag::VERSION 9 | s.summary = "PunchingBag hit conter and trending plugin" 10 | s.description = "PunchingBag is a hit counting and simple trending engine for Ruby on Rails" 11 | s.files = Dir['MIT-LICENSE', 'app/**/*.rb', 'lib/**/*.rb', 'lib/tasks/*.rake'] 12 | s.test_files = Dir['spec/**/*'] 13 | s.require_path = 'lib' 14 | s.author = "Adam Crownoble" 15 | s.email = "adam@codenoble.com" 16 | s.homepage = "https://github.com/biola/punching_bag" 17 | s.license = 'MIT' 18 | s.add_dependency 'railties', '>= 3.2' 19 | s.add_dependency 'voight_kampff', '>= 1.0' 20 | s.add_development_dependency 'activerecord', '~> 5.2.3' 21 | s.add_development_dependency 'combustion', '~> 1.1' 22 | s.add_development_dependency 'rspec-its', '~> 1.3' 23 | s.add_development_dependency 'rspec-rails', '~> 3.8' 24 | s.add_development_dependency 'sqlite3', '~> 1.4' 25 | end 26 | -------------------------------------------------------------------------------- /spec/internal/app/models/article.rb: -------------------------------------------------------------------------------- 1 | class Article < ActiveRecord::Base 2 | acts_as_punchable 3 | end -------------------------------------------------------------------------------- /spec/internal/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: db/combustion_test.sqlite 4 | -------------------------------------------------------------------------------- /spec/internal/db/combustion_test.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/biola/punching_bag/5e6509399f196056665c4d8e3b1bf04332138aeb/spec/internal/db/combustion_test.sqlite -------------------------------------------------------------------------------- /spec/internal/db/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table(:articles, force: true) do |t| 3 | t.string :title 4 | t.text :content 5 | t.timestamps 6 | end 7 | 8 | create_table(:punches, force: true) do |t| 9 | t.integer :punchable_id, null: false 10 | t.string :punchable_type, null: false, limit: 20 11 | t.datetime :starts_at, null: false 12 | t.datetime :ends_at, null: false 13 | t.datetime :average_time, null: false 14 | t.integer :hits, null: false, default: 1 15 | end 16 | 17 | add_index :punches, [:average_time], name: 'index_punches_on_average_time' 18 | add_index :punches, [:punchable_type, :punchable_id], name: 'punchable_index' 19 | end -------------------------------------------------------------------------------- /spec/lib/punching_bag/acts_as_punchable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe PunchingBag::ActiveRecord::ClassMethods do 4 | 5 | let(:request) { instance_double(ActionDispatch::Request, bot?: false) } 6 | 7 | let(:article1) { Article.create title: 'Article 1', content: 'Ding, ding ding... ding. Ding. DING. DING! ' } 8 | let(:article2) { Article.create title: 'Article 2', content: 'Ding, ding ding... ding. Ding. DING. DING! ' } 9 | 10 | before do 11 | PunchingBag.punch(article1, request) 12 | PunchingBag.punch(article2, request) 13 | PunchingBag.punch(article2, request) 14 | end 15 | 16 | describe '.most_hit' do 17 | it 'finds correct result' do 18 | expect(Article.most_hit).to include(article2) 19 | end 20 | end 21 | 22 | describe '.sort_by_popularity' do 23 | it 'sorts DESC' do 24 | expect(Article.sort_by_popularity('DESC')).to eq([article2, article1]) 25 | end 26 | it 'sorts ASC' do 27 | expect(Article.sort_by_popularity('ASC')).to eq([article1, article2]) 28 | end 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /spec/lib/punching_bag_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe PunchingBag do 4 | let(:article) { Article.create title: 'Hector', content: 'Ding, ding ding... ding. Ding. DING. DING! ' } 5 | 6 | subject { PunchingBag } 7 | 8 | describe '.punch' do 9 | let(:request) { nil } 10 | 11 | context 'when request is from a bot' do 12 | let(:request) { instance_double(ActionDispatch::Request, bot?: true) } 13 | 14 | it 'does nothing' do 15 | expect(PunchingBag.punch(article, request)).to be false 16 | end 17 | end 18 | 19 | context 'when the request is valid' do 20 | let(:request) { instance_double(ActionDispatch::Request, bot?: false) } 21 | 22 | it 'creates a new punch' do 23 | expect { PunchingBag.punch(article, request) }.to change { Punch.count }.by 1 24 | end 25 | end 26 | 27 | context 'when there is no request' do 28 | it 'creates a new punch' do 29 | expect { PunchingBag.punch(article) }.to change { Punch.count }.by 1 30 | end 31 | end 32 | 33 | context 'when count is more than one' do 34 | it 'creates a new punch with a higher count' do 35 | expect { PunchingBag.punch(article, nil, 2) }.to change { Punch.sum(:hits) }.by 2 36 | end 37 | end 38 | end 39 | 40 | describe '.average_time' do 41 | let(:time) { Time.zone.now.beginning_of_day } 42 | let(:punch_1) { Punch.new(average_time: time + 15.seconds, hits: 2) } 43 | let(:punch_2) { Punch.new(average_time: time + 30.seconds, hits: 4) } 44 | 45 | it 'finds an average time for multiple punches' do 46 | expect(PunchingBag.average_time(punch_1, punch_2)).to eql (time + 25.seconds) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/models/punch_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Punch do 4 | let(:day) { Time.now.beginning_of_day } 5 | let(:month) { Time.now.beginning_of_month } 6 | let(:year) { Time.now.beginning_of_year } 7 | 8 | let(:attrs) { {} } 9 | let(:article) { Article.create title: 'Bluths', content: "I know, I just call her Annabelle cause she's shaped like a... she's the belle of the ball!" } 10 | let(:punch) { Punch.new attrs.merge(punchable: article) } 11 | 12 | subject { punch } 13 | before { subject.valid? } # sets default values 14 | 15 | context 'with one hit' do 16 | its(:hits) { should eql 1 } 17 | its(:jab?) { should be true } 18 | its(:combo?) { should be false } 19 | end 20 | 21 | context 'with two hits' do 22 | let(:attrs) { {hits: 2} } 23 | 24 | its(:hits) { should eql 2 } 25 | its(:jab?) { should be false } 26 | its(:combo?) { should be true } 27 | end 28 | 29 | context 'with start time same as end time' do 30 | its(:timeframe) { should eql :second } 31 | its(:day_combo?) { should be false } 32 | its(:month_combo?) { should be false } 33 | its(:year_combo?) { should be false } 34 | end 35 | 36 | context 'with start time in the same day as end time' do 37 | let(:attrs) { {starts_at: day + 1.hour, ends_at: day + 2.hours } } 38 | 39 | its(:timeframe) { should eql :day } 40 | its(:day_combo?) { should be true } 41 | its(:month_combo?) { should be false } 42 | its(:year_combo?) { should be false } 43 | end 44 | 45 | context 'with start time in the same month as end time' do 46 | let(:attrs) { {starts_at: month + 1.day, ends_at: month + 2.days } } 47 | 48 | its(:timeframe) { should eql :month } 49 | its(:day_combo?) { should be false } 50 | its(:month_combo?) { should be true } 51 | its(:year_combo?) { should be false } 52 | end 53 | 54 | context 'with start time in the same year as end time' do 55 | let(:attrs) { {starts_at: year + 1.month, ends_at: year + 2.months } } 56 | 57 | its(:timeframe) { should eql :year } 58 | its(:day_combo?) { should be false } 59 | its(:month_combo?) { should be false } 60 | its(:year_combo?) { should be true } 61 | end 62 | 63 | context 'with only one punch on a day' do 64 | let(:other_punch) { nil } 65 | before { punch.save! } 66 | 67 | describe '#combine_with' do 68 | it { expect { punch.combine_with other_punch }.to_not change { Punch.count } } 69 | end 70 | end 71 | 72 | context 'with another punch on the same day' do 73 | let(:attrs) { {hits: 1, starts_at: day + 1.hour } } 74 | let!(:other_punch) { Punch.create punchable: article, starts_at: day + 2.hours } 75 | let!(:next_week_punch) { Punch.create punchable: article, starts_at: day + 7.days } 76 | 77 | before { punch.save! } 78 | 79 | describe '#combine_with' do 80 | it 'destroys the punch' do 81 | expect { punch.combine_with other_punch }.to change { punch.destroyed? }.from(false).to true 82 | end 83 | 84 | it 'combines the hits' do 85 | expect { punch.combine_with other_punch }.to change { other_punch.hits }.from(1).to 2 86 | end 87 | 88 | it 'changes starts_at or ends_at' do 89 | expect { punch.combine_with other_punch }.to change { other_punch.starts_at }.from(day + 2.hours).to(day + 1.hour) 90 | end 91 | 92 | it 'changes the average_time' do 93 | expect { punch.combine_with other_punch }.to change { other_punch.average_time }.from(day + 2.hours).to(day + 90.minutes) 94 | end 95 | end 96 | 97 | describe '#find_combo_for' do 98 | it 'finds the other punch in the day' do 99 | expect(punch.find_combo_for(:day)).to eql other_punch 100 | end 101 | 102 | it "does't find the next week punch" do 103 | expect(punch.find_combo_for(:day)).to_not eql next_week_punch 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/models/punchable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Article do 4 | let(:article_1) { Article.create title: 'Bacon', content: 'Bacon ipsum dolor sit amet turkey short ribs tri-tip' } 5 | let(:article_2) { Article.create title: 'Hipsters', content: 'American Apparel aute Banksy officia ugh.' } 6 | let(:article_3) { Article.create title: 'Lebowski', content: 'Lebowski ipsum over the line! Dolor sit amet, consectetur adipiscing elit praesent ac.' } 7 | 8 | # Instance methods 9 | describe 'Article' do 10 | subject { article_1 } 11 | 12 | describe '#hits' do 13 | context 'with no hits' do 14 | its(:hits) { should eql 0 } 15 | end 16 | 17 | context 'with one hit' do 18 | before { subject.punch } 19 | its(:hits) { should eql 1 } 20 | end 21 | end 22 | 23 | describe '#punch' do 24 | it 'incleases hits by one' do 25 | expect { subject.punch }.to change { subject.hits }.by 1 26 | end 27 | 28 | context 'when count is set to two' do 29 | it 'increases hits by two' do 30 | expect { subject.punch(nil, count: 2) }.to change { subject.hits }.by 2 31 | end 32 | end 33 | end 34 | end 35 | 36 | # Class methods 37 | describe 'Article' do 38 | subject { Article } 39 | 40 | before do 41 | 2.times { article_3.punch } 42 | article_1.punch 43 | end 44 | 45 | describe '.most_hit' do 46 | its(:most_hit) { should include article_3 } 47 | its(:most_hit) { should include article_1 } 48 | its(:most_hit) { should_not include article_2 } 49 | 50 | its('most_hit.first') { should eql article_3 } 51 | its('most_hit.second') { should eql article_1 } 52 | end 53 | 54 | describe '.sort_by_popularity' do 55 | its(:sort_by_popularity) { should include article_1 } 56 | its(:sort_by_popularity) { should include article_2 } 57 | its(:sort_by_popularity) { should include article_3 } 58 | 59 | its('sort_by_popularity.first') { should eql article_3 } 60 | its('sort_by_popularity.second') { should eql article_1 } 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] = 'test' 2 | 3 | require 'bundler/setup' 4 | require 'combustion' 5 | 6 | Bundler.require :default 7 | 8 | Combustion.initialize! :active_record 9 | 10 | require 'rspec/rails' 11 | require 'rspec/its' 12 | 13 | require 'active_support/time' 14 | require 'ostruct' 15 | 16 | RSpec.configure do |config| 17 | config.use_transactional_fixtures = true 18 | end 19 | --------------------------------------------------------------------------------