Analytics: refactoring for speed improvements (#3072)
* Use proper auth chain before checking parameters * Refactor AnalyticsController * Add a bunch of test and rewrite reactions counts with a single query * Add tests for follows totals * Use round and add more refactoring * Add tests to group by day to fallback during refactoring * Use group by to calculate comments count by day * Use group by to calculate follows count by day * Use group by to calculate reactions stats by day * Use group by to calculate page_views stats by day * Move calculations per day back in the cached block * Add a few comments and a little bit of refactoring * Add indexes (concurrently) for analytics * Make tests more time robust * Use a date range cache related to the requesting user or org * Freezing time in UTC should help when tests are ran on time zones over the day line * Remove explicit returns * Page title is escaped, guard against that * Move Analytics service spec in the proper place
This commit is contained in:
parent
7d89a55d76
commit
a8b7f6eea4
9 changed files with 591 additions and 208 deletions
|
|
@ -1,77 +1,72 @@
|
|||
module Api
|
||||
module V0
|
||||
class AnalyticsController < ApiController
|
||||
respond_to :json
|
||||
|
||||
rescue_from ArgumentError, with: :error_unprocessable_entity
|
||||
rescue_from UnauthorizedError, with: :error_unauthorized
|
||||
|
||||
before_action :authenticate_with_api_key_or_current_user
|
||||
before_action :authorize_pro_user
|
||||
before_action :authorize_user_organization
|
||||
before_action :load_owner
|
||||
before_action :validate_date_params, only: [:historical]
|
||||
|
||||
def totals
|
||||
user = get_authenticated_user!
|
||||
|
||||
data = if params[:organization_id]
|
||||
org = Organization.find_by(id: params[:organization_id])
|
||||
raise UnauthorizedError unless org && user.org_member?(org)
|
||||
|
||||
AnalyticsService.new(org, article_id: params[:article_id]).totals
|
||||
else
|
||||
AnalyticsService.new(user, article_id: params[:article_id]).totals
|
||||
end
|
||||
analytics = AnalyticsService.new(@owner, article_id: analytics_params[:article_id])
|
||||
data = analytics.totals
|
||||
render json: data.to_json
|
||||
end
|
||||
|
||||
def historical
|
||||
raise ArgumentError, "Required 'start' parameter is missing" if params[:start].blank?
|
||||
raise ArgumentError, "Date parameters 'start' or 'end' must be in the format of 'yyyy-mm-dd'" unless valid_date_params?
|
||||
|
||||
user = get_authenticated_user!
|
||||
|
||||
data = if params[:organization_id]
|
||||
org = Organization.find_by(id: params[:organization_id])
|
||||
raise UnauthorizedError unless org && user.org_member?(org)
|
||||
|
||||
AnalyticsService.new(org, start_date: params[:start], end_date: params[:end], article_id: params[:article_id]).stats_grouped_by_day
|
||||
else
|
||||
AnalyticsService.new(user, start_date: params[:start], end_date: params[:end], article_id: params[:article_id]).stats_grouped_by_day
|
||||
end
|
||||
analytics = AnalyticsService.new(
|
||||
@owner,
|
||||
start_date: params[:start], end_date: params[:end], article_id: params[:article_id],
|
||||
)
|
||||
data = analytics.grouped_by_day
|
||||
render json: data.to_json
|
||||
end
|
||||
|
||||
def past_day
|
||||
user = get_authenticated_user!
|
||||
|
||||
data = if params[:organization_id]
|
||||
org = Organization.find_by(id: params[:organization_id])
|
||||
raise UnauthorizedError unless org && user.org_member?(org)
|
||||
|
||||
AnalyticsService.new(org, start_date: 1.day.ago, article_id: params[:article_id]).stats_grouped_by_day
|
||||
else
|
||||
AnalyticsService.new(user, start_date: 1.day.ago, article_id: params[:article_id]).stats_grouped_by_day
|
||||
end
|
||||
analytics = AnalyticsService.new(
|
||||
@owner, start_date: 1.day.ago, article_id: params[:article_id]
|
||||
)
|
||||
data = analytics.grouped_by_day
|
||||
render json: data.to_json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_authenticated_user!
|
||||
user = if request.headers["api-key"].blank?
|
||||
current_user
|
||||
else
|
||||
api_secret = ApiSecret.find_by(secret: request.headers["api-key"])
|
||||
raise UnauthorizedError if api_secret.blank?
|
||||
def authorize_pro_user
|
||||
raise UnauthorizedError unless @user&.has_role?(:pro)
|
||||
end
|
||||
|
||||
api_secret.user
|
||||
end
|
||||
def authorize_user_organization
|
||||
return unless analytics_params[:organization_id]
|
||||
|
||||
raise UnauthorizedError unless user.present? && user.has_role?(:pro)
|
||||
@org = Organization.find_by(id: analytics_params[:organization_id])
|
||||
raise UnauthorizedError unless @org && @user.org_member?(@org)
|
||||
end
|
||||
|
||||
user
|
||||
def load_owner
|
||||
@owner = @org || @user
|
||||
end
|
||||
|
||||
def validate_date_params
|
||||
raise ArgumentError, "Required 'start' parameter is missing" if analytics_params[:start].blank?
|
||||
raise ArgumentError, "Date parameters 'start' or 'end' must be in the format of 'yyyy-mm-dd'" unless valid_date_params?
|
||||
end
|
||||
|
||||
def analytics_params
|
||||
params.permit(:organization_id, :article_id, :start, :end)
|
||||
end
|
||||
|
||||
def valid_date_params?
|
||||
date_regex = /\A\d{4}-\d{1,2}-\d{1,2}\Z/ # for example, 2019-03-22 or 2019-2-1
|
||||
if params[:end]
|
||||
(params[:start] =~ date_regex)&.zero? && (params[:end] =~ date_regex)&.zero?
|
||||
if analytics_params[:end]
|
||||
(analytics_params[:start] =~ date_regex)&.zero? && (analytics_params[:end] =~ date_regex)&.zero?
|
||||
else
|
||||
(params[:start] =~ date_regex)&.zero?
|
||||
(analytics_params[:start] =~ date_regex)&.zero?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ class Api::V0::ApiController < ApplicationController
|
|||
render json: { error: "not found", status: 404 }, status: :not_found
|
||||
end
|
||||
|
||||
def authenticate_with_api_key_or_current_user
|
||||
if request.headers["api-key"]
|
||||
authenticate_with_api_key
|
||||
else
|
||||
@user = current_user
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_with_api_key
|
||||
api_key = request.headers["api-key"]
|
||||
return error_unauthorized unless api_key
|
||||
|
|
@ -52,10 +60,10 @@ class Api::V0::ApiController < ApplicationController
|
|||
|
||||
# guard against timing attacks
|
||||
# see <https://www.slideshare.net/NickMalcolm/timing-attacks-and-ruby-on-rails>
|
||||
if ActiveSupport::SecurityUtils.secure_compare(api_secret.secret, api_key) # rubocop:disable Style/GuardClause
|
||||
if ActiveSupport::SecurityUtils.secure_compare(api_secret.secret, api_key)
|
||||
@user = api_secret.user
|
||||
else
|
||||
return error_unauthorized
|
||||
error_unauthorized
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,110 +8,179 @@ class AnalyticsService
|
|||
load_data
|
||||
end
|
||||
|
||||
# Totals computes total counts for comments, reactions, follows and page views
|
||||
def totals
|
||||
total_views = article_data.sum(:page_views_count)
|
||||
logged_in_page_view_data = page_view_data.where.not(user_id: nil)
|
||||
average_read_time_in_seconds = average_read_time(logged_in_page_view_data)
|
||||
|
||||
{
|
||||
comments: {
|
||||
total: comment_data.size
|
||||
},
|
||||
reactions: {
|
||||
total: reaction_data.size,
|
||||
like: reaction_data.count { |rxn| rxn.category == "like" },
|
||||
readinglist: reaction_data.count { |rxn| rxn.category == "readinglist" },
|
||||
unicorn: reaction_data.count { |rxn| rxn.category == "unicorn" }
|
||||
},
|
||||
follows: {
|
||||
total: follow_data.size
|
||||
},
|
||||
page_views: {
|
||||
total: total_views,
|
||||
average_read_time_in_seconds: average_read_time_in_seconds,
|
||||
total_read_time_in_seconds: average_read_time_in_seconds * total_views
|
||||
}
|
||||
comments: { total: comment_data.size },
|
||||
follows: { total: follow_data.size },
|
||||
reactions: calculate_reactions_totals,
|
||||
page_views: calculate_page_views_totals
|
||||
}
|
||||
end
|
||||
|
||||
def stats_grouped_by_day
|
||||
result = {}
|
||||
# Grouped by day computes counts for comments, reactions, follows and page views per each day
|
||||
def grouped_by_day
|
||||
return {} unless start_date && end_date
|
||||
|
||||
(start_date.to_date..end_date.to_date).each do |date|
|
||||
result[date.to_s(:iso)] = cached_data_for_date(date)
|
||||
# cache all stats in the date range for the requested user or organization
|
||||
cache_key = "analytics-for-dates-#{start_date}-#{end_date}-#{user_or_org.class.name}-#{user_or_org.id}"
|
||||
|
||||
Rails.cache.fetch(cache_key, expires_in: 7.days) do
|
||||
# 1. calculate all stats using group queries at once
|
||||
comments_stats_per_day = calculate_comments_stats_per_day(comment_data)
|
||||
follows_stats_per_day = calculate_follows_stats_per_day(follow_data)
|
||||
reactions_stats_per_day = calculate_reactions_stats_per_day(reaction_data)
|
||||
page_views_stats_per_day = calculate_page_views_stats_per_day(page_view_data)
|
||||
|
||||
# 2. build the final hash, one per each day
|
||||
stats = {}
|
||||
(start_date.to_date..end_date.to_date).each do |date|
|
||||
stats[date.iso8601] = stats_per_day(
|
||||
date,
|
||||
comments_stats: comments_stats_per_day,
|
||||
follows_stats: follows_stats_per_day,
|
||||
reactions_stats: reactions_stats_per_day,
|
||||
page_views_stats: page_views_stats_per_day,
|
||||
)
|
||||
end
|
||||
|
||||
stats
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user_or_org, :start_date, :end_date, :article_data, :reaction_data, :comment_data, :follow_data, :page_view_data
|
||||
attr_reader(
|
||||
:user_or_org, :start_date, :end_date,
|
||||
:article_data, :reaction_data, :comment_data, :follow_data, :page_view_data
|
||||
)
|
||||
|
||||
def load_data
|
||||
@article_data = Article.published.where("#{user_or_org.class.name.downcase}_id" => user_or_org.id)
|
||||
if @article_id
|
||||
@article_data = @article_data.where(id: @article_id)
|
||||
raise UnauthorizedError if @article_data.blank?
|
||||
|
||||
article_ids = @article_id
|
||||
# check article_id is published and belongs to the user/org
|
||||
raise ArgumentError unless @article_data.exists?
|
||||
|
||||
article_ids = [@article_id]
|
||||
else
|
||||
article_ids = @article_data.pluck(:id)
|
||||
end
|
||||
|
||||
if @start_date && @end_date
|
||||
@reaction_data = Reaction.where(reactable_id: article_ids, reactable_type: "Article").
|
||||
where(created_at: @start_date..@end_date).
|
||||
where("points > 0")
|
||||
@comment_data = Comment.where(commentable_id: article_ids, commentable_type: "Article").
|
||||
where(created_at: @start_date..@end_date).
|
||||
where("score > 0")
|
||||
@page_view_data = PageView.where(article_id: article_ids).where(created_at: @start_date..@end_date)
|
||||
else
|
||||
@reaction_data = Reaction.where(reactable_id: article_ids, reactable_type: "Article").where("points > 0")
|
||||
@comment_data = Comment.where(commentable_id: article_ids, commentable_type: "Article").where("score > 0")
|
||||
@page_view_data = PageView.where(article_id: article_ids)
|
||||
end
|
||||
# prepare relations for metrics
|
||||
@comment_data = Comment.
|
||||
where(commentable_id: article_ids, commentable_type: "Article").
|
||||
where("score > 0")
|
||||
@follow_data = Follow.
|
||||
where(followable_type: user_or_org.class.name, followable_id: user_or_org.id)
|
||||
@reaction_data = Reaction.
|
||||
where(reactable_id: article_ids, reactable_type: "Article").
|
||||
where("points > 0")
|
||||
@page_view_data = PageView.where(article_id: article_ids)
|
||||
|
||||
@follow_data = Follow.where(followable_type: user_or_org.class.name, followable_id: user_or_org.id)
|
||||
# filter data by date if needed
|
||||
return unless start_date && end_date
|
||||
|
||||
@comment_data = @comment_data.where(created_at: @start_date..@end_date)
|
||||
@reaction_data = @reaction_data.where(created_at: @start_date..@end_date)
|
||||
@page_view_data = @page_view_data.where(created_at: @start_date..@end_date)
|
||||
end
|
||||
|
||||
def average_read_time(page_view_data)
|
||||
page_view_data.size.positive? ? page_view_data.sum(:time_tracked_in_seconds) / page_view_data.size : 0
|
||||
def calculate_reactions_totals
|
||||
# NOTE: the order of the keys needs to be the same as the one of the counts
|
||||
keys = %i[total like readinglist unicorn]
|
||||
counts = reaction_data.pluck(
|
||||
Arel.sql("COUNT(*)"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE category = 'like')"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE category = 'readinglist')"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE category = 'unicorn')"),
|
||||
).first
|
||||
|
||||
# this transforms the counts, eg. [1, 0, 1, 0]
|
||||
# in a hash, eg. {total: 1, like: 0, readinglist: 1, unicorn: 0}
|
||||
keys.zip(counts).to_h
|
||||
end
|
||||
|
||||
def cached_data_for_date(date)
|
||||
expiration_date = if date == Time.current.to_date
|
||||
30.minutes
|
||||
else
|
||||
7.days
|
||||
end
|
||||
def calculate_page_views_totals
|
||||
total_views = article_data.sum(:page_views_count)
|
||||
logged_in_page_view_data = page_view_data.where.not(user_id: nil)
|
||||
average = logged_in_page_view_data.pluck(Arel.sql("AVG(time_tracked_in_seconds)")).first
|
||||
average_read_time_in_seconds = (average || 0).round # average is a BigDecimal
|
||||
|
||||
Rails.cache.fetch("analytics-for-date-#{date}-#{user_or_org.class.name}-#{user_or_org.id}", expires_in: expiration_date) do
|
||||
reaction_data_of_date = reaction_data.where("date(created_at) = ?", date)
|
||||
logged_in_page_view_data = page_view_data.where("date(created_at) = ?", date).where.not(user_id: nil)
|
||||
average_read_time_in_seconds = average_read_time(logged_in_page_view_data)
|
||||
total_views = page_view_data.where("date(created_at) = ?", date).sum(:counts_for_number_of_views)
|
||||
{
|
||||
total: total_views,
|
||||
average_read_time_in_seconds: average_read_time_in_seconds,
|
||||
total_read_time_in_seconds: average_read_time_in_seconds * total_views
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
comments: {
|
||||
total: comment_data.where("date(created_at) = ?", date).size
|
||||
},
|
||||
reactions: {
|
||||
total: reaction_data_of_date.size,
|
||||
like: reaction_data_of_date.where("category = ?", "like").size,
|
||||
readinglist: reaction_data_of_date.where("category = ?", "readinglist").size,
|
||||
unicorn: reaction_data_of_date.where("category = ?", "unicorn").size
|
||||
},
|
||||
page_views: {
|
||||
total: total_views,
|
||||
total_read_time_in_seconds: average_read_time_in_seconds * total_views,
|
||||
average_read_time_in_seconds: average_read_time_in_seconds
|
||||
},
|
||||
follows: {
|
||||
total: follow_data.where("date(created_at) = ?", date).size
|
||||
}
|
||||
def calculate_comments_stats_per_day(comment_data)
|
||||
# AR returns a hash with date => count, we transform it using ISO dates for convenience
|
||||
comment_data.group("DATE(created_at)").count.transform_keys(&:iso8601)
|
||||
end
|
||||
|
||||
def calculate_follows_stats_per_day(follow_data)
|
||||
# AR returns a hash with date => count, we transform it using ISO dates for convenience
|
||||
follow_data.group("DATE(created_at)").count.transform_keys(&:iso8601)
|
||||
end
|
||||
|
||||
def calculate_reactions_stats_per_day(reaction_data)
|
||||
# we issue one single query that contains all requested aggregates
|
||||
# and that groups them by date
|
||||
reactions = reaction_data.select(
|
||||
Arel.sql("DATE(created_at)").as("date"),
|
||||
Arel.sql("COUNT(*)").as("total"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE category = 'like')").as("like"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE category = 'readinglist')").as("readinglist"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE category = 'unicorn')").as("unicorn"),
|
||||
).group("DATE(created_at)")
|
||||
|
||||
# this transforms the collection of pseudo Reaction objects previously selected
|
||||
# in a hash, eg. {total: 1, like: 0, readinglist: 1, unicorn: 0}
|
||||
reactions.each_with_object({}) do |reaction, hash|
|
||||
hash[reaction.date.iso8601] = {
|
||||
total: reaction.total,
|
||||
like: reaction.like,
|
||||
readinglist: reaction.readinglist,
|
||||
unicorn: reaction.unicorn
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_page_views_stats_per_day(page_view_data)
|
||||
# we issue one single query that contains all requested aggregates
|
||||
# and that groups them by date
|
||||
page_views = page_view_data.select(
|
||||
Arel.sql("DATE(created_at)").as("date"),
|
||||
Arel.sql("SUM(counts_for_number_of_views)").as("total"),
|
||||
# count the average only for logged in users
|
||||
Arel.sql("AVG(time_tracked_in_seconds) FILTER (WHERE user_id IS NOT NULL)").as("average"),
|
||||
).group("DATE(created_at)")
|
||||
|
||||
# this transforms the collection of pseudo PageView objects previously selected
|
||||
# in a hash, eg. {total: 2, average_read_time_in_seconds: 10, total_read_time_in_seconds: 20}
|
||||
page_views.each_with_object({}) do |page_view, hash|
|
||||
average = page_view.average.round # average is a BigDecimal
|
||||
hash[page_view.date.iso8601] = {
|
||||
total: page_view.total,
|
||||
average_read_time_in_seconds: average,
|
||||
total_read_time_in_seconds: page_view.total * average
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def stats_per_day(date, comments_stats:, follows_stats:, reactions_stats:, page_views_stats:)
|
||||
# we need these defaults because SQL doesn't return any data for dates that don't have any
|
||||
default_reactions_stats = { total: 0, like: 0, readinglist: 0, unicorn: 0 }
|
||||
default_page_views_stats = { total: 0, average_read_time_in_seconds: 0, total_read_time_in_seconds: 0 }
|
||||
iso_date = date.iso8601
|
||||
|
||||
{
|
||||
comments: { total: comments_stats[iso_date] || 0 },
|
||||
follows: { total: follows_stats[iso_date] || 0 },
|
||||
reactions: reactions_stats[iso_date] || default_reactions_stats,
|
||||
page_views: page_views_stats[iso_date] || default_page_views_stats
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
16
db/migrate/20190607110030_add_indexes_for_analytics.rb
Normal file
16
db/migrate/20190607110030_add_indexes_for_analytics.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
class AddIndexesForAnalytics < ActiveRecord::Migration[5.2]
|
||||
# transactions are disabled to allow PostgreSQL to add indexes concurrently
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
# concurrent creation avoids downtime during production deploy because
|
||||
# indexes get created in the background
|
||||
add_index :articles, :published, algorithm: :concurrently
|
||||
add_index :comments, :created_at, algorithm: :concurrently
|
||||
add_index :comments, :score, algorithm: :concurrently
|
||||
add_index :follows, :created_at, algorithm: :concurrently
|
||||
add_index :page_views, :created_at, algorithm: :concurrently
|
||||
add_index :reactions, :created_at, algorithm: :concurrently
|
||||
add_index :reactions, :points, algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2019_06_06_202826) do
|
||||
ActiveRecord::Schema.define(version: 2019_06_07_110030) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -138,6 +138,7 @@ ActiveRecord::Schema.define(version: 2019_06_06_202826) do
|
|||
t.index ["featured_number"], name: "index_articles_on_featured_number"
|
||||
t.index ["hotness_score"], name: "index_articles_on_hotness_score"
|
||||
t.index ["path"], name: "index_articles_on_path"
|
||||
t.index ["published"], name: "index_articles_on_published"
|
||||
t.index ["published_at"], name: "index_articles_on_published_at"
|
||||
t.index ["slug"], name: "index_articles_on_slug"
|
||||
t.index ["user_id"], name: "index_articles_on_user_id"
|
||||
|
|
@ -291,6 +292,8 @@ ActiveRecord::Schema.define(version: 2019_06_06_202826) do
|
|||
t.integer "user_id"
|
||||
t.index ["ancestry"], name: "index_comments_on_ancestry"
|
||||
t.index ["commentable_id", "commentable_type"], name: "index_comments_on_commentable_id_and_commentable_type"
|
||||
t.index ["created_at"], name: "index_comments_on_created_at"
|
||||
t.index ["score"], name: "index_comments_on_score"
|
||||
t.index ["user_id"], name: "index_comments_on_user_id"
|
||||
end
|
||||
|
||||
|
|
@ -378,6 +381,7 @@ ActiveRecord::Schema.define(version: 2019_06_06_202826) do
|
|||
t.string "follower_type", null: false
|
||||
t.float "points", default: 1.0
|
||||
t.datetime "updated_at"
|
||||
t.index ["created_at"], name: "index_follows_on_created_at"
|
||||
t.index ["followable_id", "followable_type"], name: "fk_followables"
|
||||
t.index ["follower_id", "follower_type"], name: "fk_follows"
|
||||
end
|
||||
|
|
@ -595,6 +599,7 @@ ActiveRecord::Schema.define(version: 2019_06_06_202826) do
|
|||
t.string "user_agent"
|
||||
t.bigint "user_id"
|
||||
t.index ["article_id"], name: "index_page_views_on_article_id"
|
||||
t.index ["created_at"], name: "index_page_views_on_created_at"
|
||||
t.index ["user_id"], name: "index_page_views_on_user_id"
|
||||
end
|
||||
|
||||
|
|
@ -692,6 +697,8 @@ ActiveRecord::Schema.define(version: 2019_06_06_202826) do
|
|||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id"
|
||||
t.index ["category"], name: "index_reactions_on_category"
|
||||
t.index ["created_at"], name: "index_reactions_on_created_at"
|
||||
t.index ["points"], name: "index_reactions_on_points"
|
||||
t.index ["reactable_id"], name: "index_reactions_on_reactable_id"
|
||||
t.index ["reactable_type"], name: "index_reactions_on_reactable_type"
|
||||
t.index ["user_id"], name: "index_reactions_on_user_id"
|
||||
|
|
|
|||
|
|
@ -9,28 +9,28 @@ RSpec.describe "Api::V0::Analytics", type: :request do
|
|||
include_examples "GET /api/analytics/:endpoint authorization examples", "historical", "&start=2019-03-29"
|
||||
|
||||
context "when the start parameter is not included" do
|
||||
before { get "/api/analytics/historical" }
|
||||
before { get "/api/analytics/historical", headers: { "api-key" => pro_api_token.secret } }
|
||||
|
||||
it "renders a status of 422" do
|
||||
expect(response.status).to eq 422
|
||||
it "fails with an unprocessable entity HTTP error" do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it "renders the proper error message in JSON" do
|
||||
error_message = "Required 'start' parameter is missing"
|
||||
expect(JSON.parse(response.body)["error"]).to eq error_message
|
||||
expect(JSON.parse(response.body)["error"]).to eq(error_message)
|
||||
end
|
||||
end
|
||||
|
||||
context "when the start parameter has the incorrect format" do
|
||||
before { get "/api/analytics/historical?start=2019/2/2" }
|
||||
before { get "/api/analytics/historical?start=2019/2/2", headers: { "api-key" => pro_api_token.secret } }
|
||||
|
||||
it "renders a status of 422" do
|
||||
expect(response.status).to eq 422
|
||||
it "fails with an unprocessable entity HTTP error" do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it "renders the proper error message in JSON" do
|
||||
error_message = "Date parameters 'start' or 'end' must be in the format of 'yyyy-mm-dd'"
|
||||
expect(JSON.parse(response.body)["error"]).to eq error_message
|
||||
expect(JSON.parse(response.body)["error"]).to eq(error_message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ require "rails_helper"
|
|||
RSpec.describe "Pages", type: :request do
|
||||
describe "GET /:slug" do
|
||||
it "has proper headline" do
|
||||
page = create(:page)
|
||||
page = create(:page, title: "Edna O'Brien96")
|
||||
get "/page/#{page.slug}"
|
||||
expect(response.body).to include(page.title)
|
||||
expect(response.body).to include(CGI.escapeHTML(page.title))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe AnalyticsService, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:second_user) { create(:user) }
|
||||
let(:article) { create(:article, user: second_user) }
|
||||
let(:organization) { create(:organization) }
|
||||
|
||||
describe "initialization" do
|
||||
it "raises an error if start date is invalid" do
|
||||
expect(-> { described_class.new(user, start_date: "2000-") }).to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it "raises an error if end date is invalid" do
|
||||
expect(-> { described_class.new(user, end_date: "2000-") }).to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it "raises an error if an article id is invalid" do
|
||||
expect(-> { described_class.new(user, article_id: article.id) }).to raise_error(UnauthorizedError)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#totals" do
|
||||
it "returns totals stats for a user" do
|
||||
totals = described_class.new(user).totals
|
||||
expect(totals.keys).to eq(%i[comments reactions follows page_views])
|
||||
end
|
||||
|
||||
it "returns stats for comments for a user" do
|
||||
totals = described_class.new(user).totals
|
||||
expect(totals[:comments].keys).to eq([:total])
|
||||
end
|
||||
|
||||
it "returns stats for reactions for a user" do
|
||||
totals = described_class.new(user).totals
|
||||
expect(totals[:reactions].keys).to eq(%i[total like readinglist unicorn])
|
||||
end
|
||||
|
||||
it "returns stats for follows for a user" do
|
||||
totals = described_class.new(user).totals
|
||||
expect(totals[:follows].keys).to eq([:total])
|
||||
end
|
||||
|
||||
it "returns stats for page views for a user" do
|
||||
totals = described_class.new(user).totals
|
||||
expect(totals[:page_views].keys).to eq(%i[total average_read_time_in_seconds total_read_time_in_seconds])
|
||||
end
|
||||
|
||||
it "returns totals stats for an org" do
|
||||
totals = described_class.new(organization).totals
|
||||
expect(totals.keys).to eq(%i[comments reactions follows page_views])
|
||||
end
|
||||
end
|
||||
|
||||
describe "#stats_grouped_by_day" do
|
||||
before do
|
||||
article = create(:article, user: user, published: true)
|
||||
create(:reaction, reactable: article)
|
||||
create(:reading_reaction, reactable: article)
|
||||
create(:page_view, user: user, article: article)
|
||||
create(:follow)
|
||||
end
|
||||
|
||||
it "returns stats grouped by day" do
|
||||
stats = described_class.new(user, start_date: "2019-04-01", end_date: "2019-04-04").stats_grouped_by_day
|
||||
expect(stats.keys).to eq(["2019-04-01", "2019-04-02", "2019-04-03", "2019-04-04"])
|
||||
end
|
||||
|
||||
it "returns stats for a specific day" do
|
||||
stats = described_class.new(user, start_date: "2019-04-01", end_date: "2019-04-04").stats_grouped_by_day
|
||||
expect(stats["2019-04-01"].keys).to eq(%i[comments reactions page_views follows])
|
||||
end
|
||||
end
|
||||
end
|
||||
362
spec/services/analytics_service_spec.rb
Normal file
362
spec/services/analytics_service_spec.rb
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe AnalyticsService, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:organization) { create(:organization) }
|
||||
let(:article) { create(:article, user: user, published: true) }
|
||||
|
||||
before { Timecop.freeze(Time.current.utc) }
|
||||
|
||||
after { Timecop.return }
|
||||
|
||||
describe "initialization" do
|
||||
it "raises an error if start date is invalid" do
|
||||
expect(-> { described_class.new(user, start_date: "2000-") }).to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it "raises an error if end date is invalid" do
|
||||
expect(-> { described_class.new(user, end_date: "2000-") }).to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it "raises an error if an article does not belong to the user" do
|
||||
other_user = create(:user)
|
||||
article = create(:article, user: other_user)
|
||||
expect(-> { described_class.new(user, article_id: article.id) }).to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#totals" do
|
||||
let(:analytics_service) { described_class.new(user) }
|
||||
|
||||
it "returns totals stats for comments, reactions, follows and page views" do
|
||||
totals = analytics_service.totals
|
||||
expect(totals.keys.to_set).to eq(%i[comments reactions follows page_views].to_set)
|
||||
end
|
||||
|
||||
it "returns totals stats for an org" do
|
||||
totals = described_class.new(organization).totals
|
||||
expect(totals.keys.to_set).to eq(%i[comments reactions follows page_views].to_set)
|
||||
end
|
||||
|
||||
describe "comments stats" do
|
||||
it "returns stats" do
|
||||
stats = analytics_service.totals[:comments]
|
||||
expect(stats.keys).to eq(%i[total])
|
||||
end
|
||||
|
||||
it "returns the total number of comments" do
|
||||
create(:comment, commentable: article, score: 1)
|
||||
expected_stats = { total: 1 }
|
||||
expect(analytics_service.totals[:comments]).to eq(expected_stats)
|
||||
end
|
||||
|
||||
it "returns zero as total if there are no scored comments" do
|
||||
create(:comment, commentable: article, score: 0)
|
||||
expected_stats = { total: 0 }
|
||||
expect(analytics_service.totals[:comments]).to eq(expected_stats)
|
||||
end
|
||||
end
|
||||
|
||||
describe "reactions stats" do
|
||||
before { Reaction.where(reactable: article).delete_all }
|
||||
|
||||
it "returns stats" do
|
||||
stats = described_class.new(user).totals[:reactions]
|
||||
expect(stats.keys).to eq(%i[total like readinglist unicorn])
|
||||
end
|
||||
|
||||
it "returns the total number of reactions" do
|
||||
create(:reaction, reactable: article)
|
||||
expect(analytics_service.totals[:reactions][:total]).to eq(1)
|
||||
end
|
||||
|
||||
it "returns zero as total if there are no reactions with points" do
|
||||
reaction = create(:reaction, reactable: article)
|
||||
reaction.update_columns(points: 0.0)
|
||||
expect(analytics_service.totals[:reactions][:total]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the number of like reactions" do
|
||||
create(:reaction, reactable: article, category: :like)
|
||||
expect(analytics_service.totals[:reactions][:like]).to eq(1)
|
||||
end
|
||||
|
||||
it "returns zero as the number of like reactions" do
|
||||
create(:reaction, reactable: article, category: :unicorn)
|
||||
expect(analytics_service.totals[:reactions][:like]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the number of readinglist reactions" do
|
||||
create(:reaction, reactable: article, category: :readinglist)
|
||||
expect(analytics_service.totals[:reactions][:readinglist]).to eq(1)
|
||||
end
|
||||
|
||||
it "returns zero as the number of readinglist reactions" do
|
||||
create(:reaction, reactable: article, category: :unicorn)
|
||||
expect(analytics_service.totals[:reactions][:readinglist]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the number of unicorn reactions" do
|
||||
create(:reaction, reactable: article, category: :unicorn)
|
||||
expect(analytics_service.totals[:reactions][:unicorn]).to eq(1)
|
||||
end
|
||||
|
||||
it "returns zero as the number of unicorn reactions" do
|
||||
create(:reaction, reactable: article, category: :like)
|
||||
expect(analytics_service.totals[:reactions][:unicorn]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe "follows stats" do
|
||||
it "returns stats" do
|
||||
stats = analytics_service.totals[:follows]
|
||||
expect(stats.keys).to eq(%i[total])
|
||||
end
|
||||
|
||||
it "returns the total number of follows" do
|
||||
create(:follow, followable: user)
|
||||
expected_stats = { total: 1 }
|
||||
expect(analytics_service.totals[:follows]).to eq(expected_stats)
|
||||
end
|
||||
|
||||
it "returns zero as total if there are no follows" do
|
||||
expected_stats = { total: 0 }
|
||||
expect(analytics_service.totals[:follows]).to eq(expected_stats)
|
||||
end
|
||||
end
|
||||
|
||||
describe "page views stats" do
|
||||
it "returns stats" do
|
||||
stats = analytics_service.totals[:page_views]
|
||||
expect(stats.keys).to eq(%i[total average_read_time_in_seconds total_read_time_in_seconds])
|
||||
end
|
||||
|
||||
it "returns the total number of page views from page_views_count" do
|
||||
article.update_columns(page_views_count: 1)
|
||||
expect(analytics_service.totals[:page_views][:total]).to eq(1)
|
||||
end
|
||||
|
||||
it "returns zero as total if there are no page views" do
|
||||
expect(analytics_service.totals[:page_views][:total]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the average read time in seconds" do
|
||||
create(:page_view, user: user, article: article, time_tracked_in_seconds: 15)
|
||||
create(:page_view, user: user, article: article, time_tracked_in_seconds: 45)
|
||||
expect(analytics_service.totals[:page_views][:average_read_time_in_seconds]).to eq(30)
|
||||
end
|
||||
|
||||
it "returns zero as the average read time in seconds without views" do
|
||||
expect(analytics_service.totals[:page_views][:average_read_time_in_seconds]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the total read time in seconds" do
|
||||
article.update_columns(page_views_count: 1)
|
||||
create(:page_view, user: user, article: article, time_tracked_in_seconds: 15)
|
||||
create(:page_view, user: user, article: article, time_tracked_in_seconds: 45)
|
||||
# average read time * total_views
|
||||
expect(analytics_service.totals[:page_views][:total_read_time_in_seconds]).to eq(30)
|
||||
end
|
||||
|
||||
it "returns zero as the total read time in seconds with no page views" do
|
||||
expect(analytics_service.totals[:page_views][:total_read_time_in_seconds]).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#grouped_by_day" do
|
||||
def format_date(datetime)
|
||||
datetime.to_date.iso8601
|
||||
end
|
||||
|
||||
it "returns stats grouped by day" do
|
||||
stats = described_class.new(
|
||||
user, start_date: "2019-04-01", end_date: "2019-04-04"
|
||||
).grouped_by_day
|
||||
expect(stats.keys).to eq(["2019-04-01", "2019-04-02", "2019-04-03", "2019-04-04"])
|
||||
end
|
||||
|
||||
it "returns stats for comments, reactions, follows and page views for a specific day" do
|
||||
stats = described_class.new(user, start_date: "2019-04-01").grouped_by_day
|
||||
expect(stats["2019-04-01"].keys.to_set).to eq(%i[comments reactions page_views follows].to_set)
|
||||
end
|
||||
|
||||
it "returns stats for an org" do
|
||||
stats = described_class.new(
|
||||
organization, start_date: "2019-04-01", end_date: "2019-04-04"
|
||||
).grouped_by_day
|
||||
expect(stats.keys).to eq(["2019-04-01", "2019-04-02", "2019-04-03", "2019-04-04"])
|
||||
expect(stats["2019-04-01"].keys.to_set).to eq(%i[comments reactions page_views follows].to_set)
|
||||
end
|
||||
|
||||
describe "comments stats on a specific day" do
|
||||
it "returns stats" do
|
||||
analytics_service = described_class.new(user, start_date: "2019-04-01", end_date: "2019-04-04")
|
||||
stats = analytics_service.grouped_by_day["2019-04-01"][:comments]
|
||||
expect(stats.keys).to eq(%i[total])
|
||||
end
|
||||
|
||||
it "returns the total number of comments" do
|
||||
comment = create(:comment, commentable: article, score: 1)
|
||||
expected_stats = { total: 1 }
|
||||
date = format_date(comment.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:comments]).to eq(expected_stats)
|
||||
end
|
||||
|
||||
it "returns zero as total if there are no scored comments" do
|
||||
comment = create(:comment, commentable: article, score: 0)
|
||||
expected_stats = { total: 0 }
|
||||
date = format_date(comment.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:comments]).to eq(expected_stats)
|
||||
end
|
||||
end
|
||||
|
||||
describe "reactions stats on a specific day" do
|
||||
before { Reaction.where(reactable: article).delete_all }
|
||||
|
||||
it "returns stats" do
|
||||
analytics_service = described_class.new(user, start_date: "2019-04-01")
|
||||
stats = analytics_service.grouped_by_day["2019-04-01"][:reactions]
|
||||
expect(stats.keys).to eq(%i[total like readinglist unicorn])
|
||||
end
|
||||
|
||||
it "returns the total number of reactions" do
|
||||
reaction = create(:reaction, reactable: article)
|
||||
date = format_date(reaction.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:reactions][:total]).to eq(1)
|
||||
end
|
||||
|
||||
it "returns zero as total if there are no reactions with points" do
|
||||
reaction = create(:reaction, reactable: article)
|
||||
reaction.update_columns(points: 0.0)
|
||||
date = format_date(reaction.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:reactions][:total]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the number of like reactions" do
|
||||
reaction = create(:reaction, reactable: article, category: :like)
|
||||
date = format_date(reaction.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:reactions][:like]).to eq(1)
|
||||
end
|
||||
|
||||
it "returns zero as the number of like reactions" do
|
||||
reaction = create(:reaction, reactable: article, category: :unicorn)
|
||||
date = format_date(reaction.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:reactions][:like]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the number of readinglist reactions" do
|
||||
reaction = create(:reaction, reactable: article, category: :readinglist)
|
||||
date = format_date(reaction.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:reactions][:readinglist]).to eq(1)
|
||||
end
|
||||
|
||||
it "returns zero as the number of readinglist reactions" do
|
||||
reaction = create(:reaction, reactable: article, category: :unicorn)
|
||||
date = format_date(reaction.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:reactions][:readinglist]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the number of unicorn reactions" do
|
||||
reaction = create(:reaction, reactable: article, category: :unicorn)
|
||||
date = format_date(reaction.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:reactions][:unicorn]).to eq(1)
|
||||
end
|
||||
|
||||
it "returns zero as the number of unicorn reactions" do
|
||||
reaction = create(:reaction, reactable: article, category: :like)
|
||||
date = format_date(reaction.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:reactions][:unicorn]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe "follows stats for a specific day" do
|
||||
it "returns stats" do
|
||||
analytics_service = described_class.new(user, start_date: "2019-04-01")
|
||||
stats = analytics_service.grouped_by_day["2019-04-01"][:follows]
|
||||
expect(stats.keys).to eq(%i[total])
|
||||
end
|
||||
|
||||
it "returns the total number of follows" do
|
||||
follow = create(:follow, followable: user)
|
||||
date = format_date(follow.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expected_stats = { total: 1 }
|
||||
expect(analytics_service.grouped_by_day[date][:follows]).to eq(expected_stats)
|
||||
end
|
||||
|
||||
it "returns zero as total if there are no follows" do
|
||||
date = format_date(Time.current)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expected_stats = { total: 0 }
|
||||
expect(analytics_service.grouped_by_day[date][:follows]).to eq(expected_stats)
|
||||
end
|
||||
end
|
||||
|
||||
describe "page views stats for a specific day" do
|
||||
before { PageView.where(article: article).delete_all }
|
||||
|
||||
it "returns stats" do
|
||||
analytics_service = described_class.new(user, start_date: "2019-04-01")
|
||||
stats = analytics_service.grouped_by_day["2019-04-01"][:page_views]
|
||||
expect(stats.keys).to eq(%i[total average_read_time_in_seconds total_read_time_in_seconds])
|
||||
end
|
||||
|
||||
it "returns the total number of page views from counts_for_number_of_views" do
|
||||
pv = create(:page_view, user: user, article: article, counts_for_number_of_views: 5)
|
||||
date = format_date(pv.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:page_views][:total]).to eq(5)
|
||||
end
|
||||
|
||||
it "returns zero as total if there are no page views" do
|
||||
date = format_date(Time.current)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][:page_views][:total]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the average read time in seconds" do
|
||||
create(:page_view, user: user, article: article, time_tracked_in_seconds: 15)
|
||||
pv = create(:page_view, user: user, article: article, time_tracked_in_seconds: 45)
|
||||
date = format_date(pv.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][
|
||||
:page_views][:average_read_time_in_seconds]).to eq(30)
|
||||
end
|
||||
|
||||
it "returns zero as the average read time in seconds without views" do
|
||||
date = format_date(Time.current)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][
|
||||
:page_views][:average_read_time_in_seconds]).to eq(0)
|
||||
end
|
||||
|
||||
it "returns the total read time in seconds" do
|
||||
create(:page_view, user: user, article: article, time_tracked_in_seconds: 15)
|
||||
create(:page_view, user: user, article: article, time_tracked_in_seconds: 45)
|
||||
date = format_date(article.created_at)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
# average read time (30) * total_views (2)
|
||||
expect(analytics_service.grouped_by_day[date][
|
||||
:page_views][:total_read_time_in_seconds]).to eq(60)
|
||||
end
|
||||
|
||||
it "returns zero as the total read time in seconds with no page views" do
|
||||
date = format_date(Time.current)
|
||||
analytics_service = described_class.new(user, start_date: date)
|
||||
expect(analytics_service.grouped_by_day[date][
|
||||
:page_views][:total_read_time_in_seconds]).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue