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:
rhymes 2019-06-10 15:27:39 +02:00 committed by Ben Halpern
parent 7d89a55d76
commit a8b7f6eea4
9 changed files with 591 additions and 208 deletions

View file

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

View file

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

View file

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

View 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

View file

@ -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"

View file

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

View file

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

View file

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

View 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