Switch decorators to Rails builtin decorations - take 2 (#6125) [deploy]

* Revert "Revert "[deploy] Switch decorators from Draper to Rails builtin decorations (#6040)" (#6122) [deploy]"

This reverts commit d438349550.

* Enable serialization for decorators

* Refactor and test default home feed serialization

* Improve spec

* Applied @vaidehijoshi suggestions

* Applied @citizen428 suggestions

* Make CodeClimate happy

* Cleanup stories_index_spec.rb

* Refactor UserDecorator#cached_followed_tags to loop less
This commit is contained in:
rhymes 2020-02-19 21:30:53 +01:00 committed by GitHub
parent aee50e9ea0
commit bc385ae6d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 778 additions and 152 deletions

View file

@ -32,7 +32,6 @@ gem "ddtrace", "~> 0.32.0" # ddtrace is Datadogs tracing client for Ruby.
gem "devise", "~> 4.7" # Flexible authentication solution for Rails
gem "dogstatsd-ruby", "~> 4.7" # A client for DogStatsD, an extension of the StatsD metric server for Datadog
gem "doorkeeper", "~> 5.3" # Oauth 2 provider
gem "draper", "~> 4.0" # Draper adds an object-oriented layer of presentation logic to your Rails apps
gem "dry-struct", "~> 1.2" # Typed structs and value objects
gem "elasticsearch", "~> 7.4" # Powers DEVs core search functionality
gem "email_validator", "~> 2.0" # Email validator for Rails and ActiveModel

View file

@ -39,10 +39,6 @@ GEM
globalid (>= 0.3.6)
activemodel (5.2.4.1)
activesupport (= 5.2.4.1)
activemodel-serializers-xml (1.0.2)
activemodel (> 5.x)
activesupport (> 5.x)
builder (~> 3.1)
activerecord (5.2.4.1)
activemodel (= 5.2.4.1)
activesupport (= 5.2.4.1)
@ -233,12 +229,6 @@ GEM
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.3.1)
railties (>= 5)
draper (4.0.0)
actionpack (>= 5.0)
activemodel (>= 5.0)
activemodel-serializers-xml (>= 1.0)
activesupport (>= 5.0)
request_store (>= 1.0)
dry-configurable (0.11.1)
concurrent-ruby (~> 1.0)
dry-core (~> 0.4, >= 0.4.7)
@ -887,7 +877,6 @@ DEPENDENCIES
devise (~> 4.7)
dogstatsd-ruby (~> 4.7)
doorkeeper (~> 5.3)
draper (~> 4.0)
dry-struct (~> 1.2)
elasticsearch (~> 7.4)
email_validator (~> 2.0)

View file

@ -29,7 +29,8 @@ module Api
end
def create
@article = Articles::Creator.call(@user, article_params)
@article = Articles::Creator.call(@user, article_params).decorate
if @article.persisted?
render "show", status: :created, location: @article.url
else

View file

@ -88,14 +88,16 @@ class ArticlesController < ApplicationController
def edit
authorize @article
@version = @article.has_frontmatter? ? "v1" : "v2"
@user = @article.user
@organizations = @user&.organizations
end
def manage
@article = @article.decorate
authorize @article
@article = @article.decorate
@user = @article.user
@rating_vote = RatingVote.where(article_id: @article.id, user_id: @user.id).first
@buffer_updates = BufferUpdate.where(composer_user_id: @user.id, article_id: @article.id)
@ -136,13 +138,12 @@ class ArticlesController < ApplicationController
def create
authorize Article
@user = current_user
@article = Articles::Creator.call(@user, article_params_json)
article = Articles::Creator.call(current_user, article_params_json)
render json: if @article.persisted?
@article.to_json(only: [:id], methods: [:current_state_path])
render json: if article.persisted?
{ id: article.id, current_state_path: article.decorate.current_state_path }.to_json
else
@article.errors.to_json
article.errors.to_json
end
end

View file

@ -1,4 +1,16 @@
class StoriesController < ApplicationController
DEFAULT_HOME_FEED_ATTRIBUTES_FOR_SERIALIZATION = {
only: %i[
title path id user_id comments_count positive_reactions_count organization_id
reading_time video_thumbnail_url video video_duration_in_minutes language
experience_level_rating experience_level_rating_distribution cached_user cached_organization
],
methods: %i[
readable_publish_date cached_tag_list_array flare_tag class_name
cloudinary_video_url video_duration_in_minutes published_at_int published_timestamp
]
}.freeze
before_action :authenticate_user!, except: %i[index search show]
before_action :set_cache_control_headers, only: %i[index search show]

View file

@ -1,8 +1,20 @@
class ApplicationDecorator < Draper::Decorator
# Define methods for all decorated objects.
# Helpers are accessed through `helpers` (aka `h`). For example:
#
# def percent_amount
# h.number_to_percentage object.amount, precision: 2
# end
class ApplicationDecorator
include ActiveModel::Serialization
include ActiveModel::Serializers::JSON
delegate_missing_to :@object
attr_reader :object
def initialize(object)
@object = object
end
def decorated?
true
end
def self.decorate_collection(objects)
objects.map(&:decorate)
end
end

View file

@ -1,6 +1,4 @@
class ArticleDecorator < ApplicationDecorator
delegate_all
def current_state_path
published ? "/#{username}/#{slug}" : "/#{username}/#{slug}?preview=#{password}"
end
@ -26,13 +24,13 @@ class ArticleDecorator < ApplicationDecorator
end
def title_length_classification
if article.title.size > 105
if title.size > 105
"longest"
elsif article.title.size > 80
elsif title.size > 80
"longer"
elsif article.title.size > 60
elsif title.size > 60
"long"
elsif article.title.size > 22
elsif title.size > 22
"medium"
else
"short"
@ -40,12 +38,15 @@ class ArticleDecorator < ApplicationDecorator
end
def internal_utm_params(place = "additional_box")
org_slug = organization&.slug
campaign = if boosted_additional_articles
"#{organization&.slug}_boosted"
"#{org_slug}_boosted"
else
"regular"
end
"?utm_source=#{place}&utm_medium=internal&utm_campaign=#{campaign}&booster_org=#{organization&.slug}"
"?utm_source=#{place}&utm_medium=internal&utm_campaign=#{campaign}&booster_org=#{org_slug}"
end
def published_at_int

View file

@ -1,6 +1,4 @@
class CommentDecorator < ApplicationDecorator
delegate_all
LOW_QUALITY_THRESHOLD = -75
def low_quality

View file

@ -1,21 +1,30 @@
class NotificationDecorator < Draper::Decorator
delegate_all
def mocked_object(type)
struct = Struct.new(:name, :id) do
def class
second_struct = Struct.new(:name)
second_struct.new(name)
end
class NotificationDecorator < ApplicationDecorator
NOTIFIABLE_STUB = Struct.new(:name, :id) do
def class
Struct.new(:name).new(name)
end
struct.new(json_data[type]["class"]["name"], json_data[type]["id"])
end.freeze
# returns a stub notifiable object with name and id
def mocked_object(type)
return NOTIFIABLE_STUB.new("", nil) if json_data.blank?
NOTIFIABLE_STUB.new(json_data[type]["class"]["name"], json_data[type]["id"])
end
# returns the type of a milestone notification action,
# eg. "Milestone::Reaction::64"
def milestone_type
action.split("::")[1]
return "" if action.blank?
action.split("::").second
end
# returns the count of a milestone notification action,
# eg. "Milestone::Reaction::64"
def milestone_count
action.split("::")[2]
return "" if action.blank?
action.split("::").third
end
end

View file

@ -1,6 +1,4 @@
class OrganizationDecorator < ApplicationDecorator
delegate_all
def darker_color(adjustment = 0.88)
HexComparer.new([enriched_colors[:bg], enriched_colors[:text]]).brightness(adjustment)
end
@ -13,8 +11,8 @@ class OrganizationDecorator < ApplicationDecorator
}
else
{
bg: bg_color_hex || assigned_color[:bg],
text: text_color_hex || assigned_color[:text]
bg: bg_color_hex,
text: text_color_hex.presence || assigned_color[:text]
}
end
end

View file

@ -1,16 +1,17 @@
class PodcastEpisodeDecorator < ApplicationDecorator
delegate_all
def comments_to_show_count
cached_tag_list_array.include?("discuss") ? 75 : 25
end
# this method exists because podcast episodes are "commentables"
# and in some parts of the code we assume they have this method,
# but podcast episodes don't have a cached_tag_list like articles do
def cached_tag_list_array
(tag_list || "").split(", ")
tag_list
end
def readable_publish_date
return unless published_at
return "" unless published_at
if published_at.year == Time.current.year
published_at.strftime("%b %e")
@ -20,6 +21,8 @@ class PodcastEpisodeDecorator < ApplicationDecorator
end
def published_timestamp
published_at&.utc&.iso8601
return "" unless published_at
published_at.utc.iso8601
end
end

View file

@ -1 +0,0 @@
class ReactionDecorator < ApplicationDecorator; end

View file

@ -1,12 +1,11 @@
class SponsorshipDecorator < ApplicationDecorator
delegate_all
def level_color_hex
def level_background_color
hexes = {
"gold" => "linear-gradient(to right, #faf0e6 8%, #faf3e6 18%, #fcf6eb 33%);",
"silver" => "linear-gradient(to right, #e3e3e3 8%, #f0eded 18%, #e8e8e8 33%);",
"bronze" => "linear-gradient(to right, #ebe2d3 8%, #f5eee1 18%, #ede6d8 33%);"
}
hexes[level]
hexes[level].to_s
end
end

View file

@ -18,15 +18,14 @@ class UserDecorator < ApplicationDecorator
},
].freeze
delegate_all
def cached_followed_tags
Rails.cache.fetch("user-#{id}-#{updated_at}/followed_tags_11-30", expires_in: 20.hours) do
follows_query = Follow.where(follower_id: id, followable_type: "ActsAsTaggableOn::Tag").pluck(:followable_id, :points)
tags = Tag.where(id: follows_query.map { |f| f[0] }).order("hotness_score DESC")
tags.each do |t|
follow_query_item = follows_query.detect { |f| f[0] == t.id }
t.points = follow_query_item[1]
follows = Follow.where(follower_id: id, followable_type: "ActsAsTaggableOn::Tag").pluck(:followable_id, :points)
follows_map = follows.to_h
tags = Tag.where(id: follows_map.keys).order(hotness_score: :desc)
tags.each do |tag|
tag.points = follows_map[tag.id]
end
tags
end
@ -44,20 +43,21 @@ class UserDecorator < ApplicationDecorator
}
else
{
bg: bg_color_hex || assigned_color[:bg],
text: text_color_hex || assigned_color[:text]
bg: bg_color_hex,
text: text_color_hex
}
end
end
def config_body_class
body_class = ""
body_class += config_theme.tr("_", "-")
body_class += " #{config_font.tr('_', '-')}-article-body"
body_class += " pro-status-#{pro?}"
body_class += " trusted-status-#{trusted}"
body_class += " #{config_navbar.tr('_', '-')}-navbar-config"
body_class
body_class = [
config_theme.tr("_", "-"),
"#{config_font.tr('_', '-')}-article-body",
"pro-status-#{pro?}",
"trusted-status-#{trusted}",
"#{config_navbar.tr('_', '-')}-navbar-config",
]
body_class.join(" ")
end
def dark_theme?
@ -95,11 +95,9 @@ class UserDecorator < ApplicationDecorator
colors[id % 10]
end
# returns true if the user has been suspended and has no content
def fully_banished?
# User suspended and has no content
articles_count.zero? &&
comments_count.zero? &&
banned
articles_count.zero? && comments_count.zero? && banned
end
def stackbit_integration?

View file

@ -0,0 +1,4 @@
# raised when an object or collection tries to decorate itself,
# without having an inferrable decorator
class UninferrableDecoratorError < NameError
end

View file

@ -20,4 +20,31 @@ class ApplicationRecord < ActiveRecord::Base
result.clear # PG::Result is manually managed in memory, we need to release its resources
count
end
# Decorate object with appropriate decorator
def decorate
self.class.decorator_class.new(self)
end
def decorated?
false
end
# Decorate collection with appropriate decorator
def self.decorate
decorator_class.decorate_collection(all)
end
# Infers the decorator class to be used by (e.g. `User` maps to `UserDecorator`).
# adapted from https://github.com/drapergem/draper/blob/157eb955072a941e6455e0121fca09a989fcbc21/lib/draper/decoratable.rb#L71
def self.decorator_class(called_on = self)
prefix = respond_to?(:model_name) ? model_name : name
decorator_name = "#{prefix}Decorator"
decorator_name_constant = decorator_name.safe_constantize
return decorator_name_constant unless decorator_name_constant.nil?
return superclass.decorator_class(called_on) if superclass.respond_to?(:decorator_class)
raise UninferrableDecoratorError, "Could not infer a decorator for #{called_on.class.name}."
end
end

View file

@ -98,10 +98,6 @@ class PodcastEpisode < ApplicationRecord
ActionView::Base.full_sanitizer.sanitize(processed_html)
end
def published_at_date_slashes
published_at&.to_date&.strftime("%m/%d/%Y")
end
def user
podcast
end

View file

@ -14,6 +14,7 @@ module Articles
raise if RateLimitChecker.new(user).limit_by_action("published_article_creation")
article = save_article
if article.persisted?
NotificationSubscription.create(user: user, notifiable_id: article.id, notifiable_type: "Article", config: "all_comments")
Notification.send_to_followers(article, "Published") if article.published?
@ -21,7 +22,7 @@ module Articles
dispatch_event(article)
end
article.decorate
article
end
private

View file

@ -2,7 +2,7 @@
<div class="sidebar-bg" id="sidebar-bg-right"></div>
<div class="side-bar sidebar-additional showing" id="sidebar-additional">
<% @organization.sponsorships.live.find_each do |sponsorship| %>
<div class="sidebar-sponsorship-level" style="background:<%= sponsorship.decorate.level_color_hex %>">
<div class="sidebar-sponsorship-level" style="background:<%= sponsorship.decorate.level_background_color %>">
<%= sponsorship.level.capitalize %> Community Sponsor ❤️
</div>
<% end %>

View file

@ -2,15 +2,8 @@
<% cache("fetched-home-articles-object-#{ApplicationConfig['HEROKU_SLUG_COMMIT']}", expires_in: 3.minutes) do %>
<div id="followed-podcasts" data-episodes="<%= @podcast_episodes.to_json(include: { podcast: { only: %i[slug title id], methods: %i[image_90] } }) %>">
</div>
<div id="home-articles-object" data-articles="
<%= @stories.to_json(
only: %i[title path id user_id comments_count positive_reactions_count organization_id
reading_time video_thumbnail_url video video_duration_in_minutes language
experience_level_rating experience_level_rating_distribution cached_user cached_organization],
methods: %i[readable_publish_date cached_tag_list_array flare_tag class_name
cloudinary_video_url video_duration_in_minutes published_at_int
published_timestamp],
) %>">
<div id="home-articles-object"
data-articles="<%= @stories.to_json(controller.class.const_get(:DEFAULT_HOME_FEED_ATTRIBUTES_FOR_SERIALIZATION)) %>">
<% 3.times do %>
<div class="single-article single-article-small-pic">
<div class="small-pic">

View file

@ -0,0 +1,40 @@
require "rails_helper"
RSpec.describe ApplicationDecorator, type: :decorator do
describe "#object" do
it "exposes the decorated object" do
obj = Object.new
expect(described_class.new(obj).object).to be(obj)
end
end
describe "#decorated?" do
it "returns true" do
obj = Object.new
expect(described_class.new(obj).decorated?).to be(true)
end
end
# as ApplicationDecorator is an abstract class, some tests also use an actual decorator
describe ".decorate_collection" do
before do
create(:sponsorship, level: :gold)
end
it "receives an ActiveRecord relation and returns an array of decorated records" do
relation = Sponsorship.gold
decorated_collection = described_class.decorate_collection(relation)
expect(decorated_collection.map(&:class)).to eq([SponsorshipDecorator])
expect(decorated_collection.map(&:object)).to eq(relation.to_a)
end
it "receives an array and returns an array of decorated records" do
relation = Sponsorship.gold
decorated_collection = described_class.decorate_collection(relation.to_a)
expect(decorated_collection.map(&:class)).to eq([SponsorshipDecorator])
expect(decorated_collection.map(&:object)).to eq(relation.to_a)
end
end
end

View file

@ -6,7 +6,37 @@ RSpec.describe ArticleDecorator, type: :decorator do
article.decorate
end
let(:article) { build_stubbed(:article) }
let(:article) { build(:article) }
let(:published_article) { create_article(published: true) }
let(:organization) { build(:organization) }
context "with serialization" do
it "serializes both the decorated object IDs and decorated methods" do
article = published_article
expected_result = { "id" => article.id, "published_at_int" => article.published_at_int }
expect(article.as_json(only: [:id], methods: [:published_at_int])).to eq(expected_result)
end
it "serializes collections of decorated objects" do
article = published_article
decorated_collection = Article.published.decorate
expected_result = [{ "id" => article.id, "published_at_int" => article.published_at_int }]
expect(decorated_collection.as_json(only: [:id], methods: [:published_at_int])).to eq(expected_result)
end
end
describe "#current_state_path" do
it "returns the path /:username/:slug when published" do
article = published_article
expect(article.current_state_path).to eq("/#{article.username}/#{article.slug}")
end
it "returns the path /:username/:slug?:password when draft" do
article = create_article(published: false)
expected_result = "/#{article.username}/#{article.slug}?preview=#{article.password}"
expect(article.current_state_path).to eq(expected_result)
end
end
describe "#processed_canonical_url" do
it "strips canonical_url" do
@ -21,6 +51,37 @@ RSpec.describe ArticleDecorator, type: :decorator do
end
end
describe "#comments_to_show_count" do
it "returns 25 if does not have a discuss tag" do
article.cached_tag_list = ""
expect(article.decorate.comments_to_show_count).to eq(25)
end
it "returns 75 if it does have a discuss tag" do
article.cached_tag_list = "discuss, python"
expect(article.decorate.comments_to_show_count).to eq(75)
end
end
describe "#cached_tag_list_array" do
it "returns no tags if the cached tag list is empty" do
article.cached_tag_list = ""
expect(article.decorate.cached_tag_list_array).to be_empty
end
it "returns cached tag list as an array" do
article.cached_tag_list = "discuss, python"
expect(article.decorate.cached_tag_list_array).to eq(%w[discuss python])
end
end
describe "#url" do
it "returns the article url" do
expected_url = "https://#{ApplicationConfig['APP_DOMAIN']}#{article.path}"
expect(article.decorate.url).to eq(expected_url)
end
end
describe "#title_length_classification" do
it "returns article title length classifications" do
article.title = "0" * 106
@ -36,6 +97,58 @@ RSpec.describe ArticleDecorator, type: :decorator do
end
end
describe "internal_utm_params" do
it "returns utm params for a boosted article" do
article.boosted_additional_articles = true
params = ["utm_medium=internal", "utm_campaign=_boosted", "booster_org="]
expected_result = "?utm_source=additional_box&#{params.join('&')}"
expect(article.decorate.internal_utm_params).to eq(expected_result)
end
it "returns utm params for a boosted article belonging to an organization" do
article.boosted_additional_articles = true
article.organization = organization
slug = organization.slug
params = ["utm_medium=internal", "utm_campaign=#{slug}_boosted", "booster_org=#{slug}"]
expected_result = "?utm_source=additional_box&#{params.join('&')}"
expect(article.decorate.internal_utm_params).to eq(expected_result)
end
it "returns utm params for a regular article" do
article.boosted_additional_articles = false
params = ["utm_medium=internal", "utm_campaign=regular", "booster_org="]
expected_result = "?utm_source=additional_box&#{params.join('&')}"
expect(article.decorate.internal_utm_params).to eq(expected_result)
end
it "returns utm params for a regular article belonging to an organization" do
article.boosted_additional_articles = false
article.organization = organization
slug = organization.slug
params = ["utm_medium=internal", "utm_campaign=regular", "booster_org=#{slug}"]
expected_result = "?utm_source=additional_box&#{params.join('&')}"
expect(article.decorate.internal_utm_params).to eq(expected_result)
end
it "returns utm params for an article in a different place" do
article.boosted_additional_articles = false
params = ["utm_medium=internal", "utm_campaign=regular", "booster_org="]
expected_result = "?utm_source=homepage&#{params.join('&')}"
expect(article.decorate.internal_utm_params("homepage")).to eq(expected_result)
end
end
describe "#published_at_int" do
it "returns the publication date as an integer" do
expect(article.decorate.published_at_int).to eq(article.published_at.to_i)
end
end
describe "#description_and_tags" do
it "creates proper description when it is not present and body is present and short, and tags are present" do
body_markdown = "---\ntitle: Title\npublished: false\ndescription:\ntags: heytag\n---\n\nHey this is the article"

View file

@ -1,26 +1,42 @@
require "rails_helper"
RSpec.describe CommentDecorator, type: :decorator do
describe "#low_quality" do
let(:threshold) { CommentDecorator::LOW_QUALITY_THRESHOLD }
context "with serialization" do
let_it_be_readonly(:comment) { create(:comment).decorate }
it "returns true if the comment is low quality" do
comment = build_stubbed(:comment, score: threshold - 1).decorate
expect(comment.low_quality).to be(true)
it "serializes both the decorated object IDs and decorated methods" do
expected_result = { "id" => comment.id, "published_timestamp" => comment.published_timestamp }
expect(comment.as_json(only: [:id], methods: [:published_timestamp])).to eq(expected_result)
end
it "returns false if the comment score is on threshold quality" do
comment = build_stubbed(:comment, score: threshold).decorate
expect(comment.low_quality).to be(false)
end
it "returns false if the comment is a good quality" do
comment = build_stubbed(:comment, score: threshold + 1).decorate
expect(comment.low_quality).to be(false)
it "serializes collections of decorated objects" do
decorated_collection = Comment.decorate
expected_result = [{ "id" => comment.id, "published_timestamp" => comment.published_timestamp }]
expect(decorated_collection.as_json(only: [:id], methods: [:published_timestamp])).to eq(expected_result)
end
end
describe "published_timestamp" do
describe "#low_quality" do
let(:threshold) { CommentDecorator::LOW_QUALITY_THRESHOLD }
let(:comment) { build(:comment) }
it "returns true if the comment is low quality" do
comment.score = threshold - 1
expect(comment.decorate.low_quality).to be(true)
end
it "returns false if the comment score is on threshold quality" do
comment.score = threshold
expect(comment.decorate.low_quality).to be(false)
end
it "returns false if the comment is a good quality" do
comment.score = threshold + 1
expect(comment.decorate.low_quality).to be(false)
end
end
describe "#published_timestamp" do
it "returns empty string if the comment is new" do
expect(Comment.new.decorate.published_timestamp).to eq("")
end

View file

@ -0,0 +1,76 @@
require "rails_helper"
RSpec.describe NotificationDecorator, type: :decorator do
let(:notification) { build(:notification) }
context "with serialization" do
let_it_be_readonly(:notification) { create(:notification).decorate }
it "serializes both the decorated object IDs and decorated methods" do
expected_result = { "id" => notification.id, "milestone_type" => notification.milestone_type }
expect(notification.as_json(only: [:id], methods: [:milestone_type])).to eq(expected_result)
end
it "serializes collections of decorated objects" do
decorated_collection = Notification.decorate
expected_result = [{ "id" => notification.id, "milestone_type" => notification.milestone_type }]
expect(decorated_collection.as_json(only: [:id], methods: [:milestone_type])).to eq(expected_result)
end
end
describe "#mocked_object" do
let(:comment) { create(:comment, commentable: create(:article, organization: create(:organization))) }
it "returns empty struct if the notification is new" do
result = notification.decorate.mocked_object("user")
expect(result.name).to be_empty
expect(result.id).to be_nil
end
it "returns empty struct class and its name if the notification is new" do
result = notification.decorate.mocked_object("user")
expect(result.class).to be_a(Struct)
expect(result.class.name).to be_empty
end
it "returns class name and id for the reactable in a struct" do
notification = Notification.send_new_comment_notifications_without_delay(comment)
result = notification.decorate.mocked_object("user")
expect(result.name).to eq("User")
expect(result.id).to eq(comment.user.id)
end
it "returns struct class and its name" do
notification = Notification.send_new_comment_notifications_without_delay(comment)
result = notification.decorate.mocked_object("user")
expect(result.class).to be_a(Struct)
expect(result.class.name).to eq("User")
end
end
describe "#milestone_type" do
it "returns empty string if there is no action" do
expect(notification.decorate.milestone_type).to be_empty
end
it "returns the type of the milestone action" do
notification = build(:notification, action: "Milestone::Reaction::64")
expect(notification.decorate.milestone_type).to eq("Reaction")
end
end
describe "#milestone_count" do
it "returns empty string if there is no action" do
expect(notification.decorate.milestone_count).to be_empty
end
it "returns the count of the milestone action" do
notification = build(:notification, action: "Milestone::Reaction::64")
expect(notification.decorate.milestone_count).to eq("64")
end
end
end

View file

@ -0,0 +1,71 @@
require "rails_helper"
RSpec.describe OrganizationDecorator, type: :decorator do
context "with serialization" do
let_it_be_readonly(:organization) { create(:organization).decorate }
it "serializes both the decorated object IDs and decorated methods" do
expected_result = { "id" => organization.id, "fully_banished?" => organization.fully_banished? }
expect(organization.as_json(only: [:id], methods: [:fully_banished?])).to eq(expected_result)
end
it "serializes collections of decorated objects" do
decorated_collection = Organization.decorate
expected_result = [{ "id" => organization.id, "fully_banished?" => organization.fully_banished? }]
expect(decorated_collection.as_json(only: [:id], methods: [:fully_banished?])).to eq(expected_result)
end
end
describe "#darker_color" do
it "returns a darker version of the assigned color if colors are blank" do
organization = build(:organization, bg_color_hex: "", text_color_hex: "")
expect(organization.decorate.darker_color).to eq("#090909")
end
it "returns a darker version of the color if bg_color_hex is present" do
organization = build(:organization, bg_color_hex: "#dddddd", text_color_hex: "#ffffff")
expect(organization.decorate.darker_color).to eq("#c2c2c2")
end
it "returns an adjusted darker version of the color" do
organization = build(:organization, bg_color_hex: "#dddddd", text_color_hex: "#ffffff")
expect(organization.decorate.darker_color(0.3)).to eq("#424242")
end
it "returns an adjusted lighter version of the color if adjustment is over 1.0" do
organization = build(:organization, bg_color_hex: "#dddddd", text_color_hex: "#ffffff")
expect(organization.decorate.darker_color(1.1)).to eq("#f3f3f3")
end
end
describe "#enriched_colors" do
it "returns the assigned colors if bg_color_hex is blank" do
organization = build(:organization, bg_color_hex: "")
expect(organization.decorate.enriched_colors).to eq(bg: "#0a0a0a", text: "#ffffff")
end
it "returns bg_color_hex and assigned text_color_hex if text_color_hex is blank" do
organization = build(:organization, bg_color_hex: "#dddddd", text_color_hex: "")
expect(organization.decorate.enriched_colors).to eq(bg: "#dddddd", text: "#ffffff")
end
it "returns bg_color_hex and text_color_hex" do
organization = build(:organization, bg_color_hex: "#dddddd", text_color_hex: "#fffff3")
expect(organization.decorate.enriched_colors).to eq(bg: "#dddddd", text: "#fffff3")
end
end
describe "#assigned_color" do
it "returns the default assigned colors" do
organization = build(:organization)
expect(organization.decorate.assigned_color).to eq(bg: "#0a0a0a", text: "#ffffff")
end
end
describe "#fully_banished?" do
it "returns false" do
organization = build(:organization)
expect(organization.decorate.fully_banished?).to be(false)
end
end
end

View file

@ -1,10 +1,80 @@
require "rails_helper"
RSpec.describe PodcastEpisodeDecorator, type: :decorator do
context "with serialization" do
let(:podcast_episode) { create(:podcast_episode).decorate }
it "serializes both the decorated object IDs and decorated methods" do
expected_result = {
"id" => podcast_episode.id, "comments_to_show_count" => podcast_episode.comments_to_show_count
}
expect(podcast_episode.as_json(only: [:id], methods: [:comments_to_show_count])).to eq(expected_result)
end
it "serializes collections of decorated objects" do
podcast_episode # for the side effect
decorated_collection = PodcastEpisode.decorate
expected_result = [
{ "id" => podcast_episode.id, "comments_to_show_count" => podcast_episode.comments_to_show_count },
]
expect(decorated_collection.as_json(only: [:id], methods: [:comments_to_show_count])).to eq(expected_result)
end
end
describe "#comments_to_show_count" do
it "returns 25 if does not have a discuss tag" do
pe = build_stubbed(:podcast_episode).decorate
expect(pe.comments_to_show_count).to eq(25)
pe = build(:podcast_episode)
expect(pe.decorate.comments_to_show_count).to eq(25)
end
it "returns 75 if it does have a discuss tag" do
pe = build(:podcast_episode, tag_list: ["discuss"])
expect(pe.decorate.comments_to_show_count).to eq(75)
end
end
describe "#cached_tag_list_array" do
it "returns no tags if the tag list is empty" do
pe = build(:podcast_episode, tag_list: [])
expect(pe.decorate.cached_tag_list_array).to be_empty
end
it "returns tag list" do
pe = build(:podcast_episode, tag_list: ["discuss"])
expect(pe.decorate.cached_tag_list_array).to eq(pe.tag_list)
end
end
describe "#readable_publish_date" do
it "returns empty string if the episode does not have a published_at" do
pe = build(:podcast_episode, published_at: nil)
expect(pe.decorate.readable_publish_date).to be_empty
end
it "returns the correct date for a same year publication" do
published_at = Time.current
pe = build(:podcast_episode, published_at: published_at)
expect(pe.decorate.readable_publish_date).to eq(published_at.strftime("%b %e"))
end
it "returns the correct date for a publication within a different year" do
published_at = 2.years.ago
pe = build(:podcast_episode, published_at: published_at)
expect(pe.decorate.readable_publish_date).to eq(published_at.strftime("%b %e '%y"))
end
end
describe "#published_timestamp" do
it "returns empty string if the episode does not have a published_at" do
pe = build(:podcast_episode, published_at: nil)
expect(pe.decorate.published_timestamp).to be_empty
end
it "returns the correct date for a published episode" do
published_at = Time.current
pe = build(:podcast_episode, published_at: published_at)
expect(pe.decorate.published_timestamp).to eq(published_at.utc.iso8601)
end
end
end

View file

@ -0,0 +1,33 @@
require "rails_helper"
RSpec.describe SponsorshipDecorator, type: :decorator do
context "with serialization" do
let_it_be_readonly(:sponsorship) { create(:sponsorship).decorate }
it "serializes both the decorated object IDs and decorated methods" do
expected_result = { "id" => sponsorship.id, "level_background_color" => sponsorship.level_background_color }
expect(sponsorship.as_json(only: [:id], methods: [:level_background_color])).to eq(expected_result)
end
it "serializes collections of decorated objects" do
decorated_collection = Sponsorship.decorate
expected_result = [{ "id" => sponsorship.id, "level_background_color" => sponsorship.level_background_color }]
expect(decorated_collection.as_json(only: [:id], methods: [:level_background_color])).to eq(expected_result)
end
end
describe "#level_background_color" do
let(:sponsorship) { build(:sponsorship) }
it "returns the correct hex for gold" do
sponsorship.level = "gold"
expected_result = "linear-gradient(to right, #faf0e6 8%, #faf3e6 18%, #fcf6eb 33%);"
expect(sponsorship.decorate.level_background_color).to eq(expected_result)
end
it "returns empty string for unsupported level" do
sponsorship.level = "media"
expect(sponsorship.decorate.level_background_color).to eq("")
end
end
end

View file

@ -1,48 +1,107 @@
require "rails_helper"
RSpec.describe UserDecorator, type: :decorator do
let(:user) { build_stubbed(:user) }
let_it_be_changeable(:saved_user) { create(:user) }
let(:user) { build(:user) }
context "with serialization" do
it "serializes both the decorated object IDs and decorated methods" do
user = saved_user.decorate
expected_result = { "id" => user.id, "dark_theme?" => user.dark_theme? }
expect(user.as_json(only: [:id], methods: [:dark_theme?])).to eq(expected_result)
end
it "serializes collections of decorated objects" do
user = saved_user.decorate
decorated_collection = User.decorate
expected_result = [{ "id" => user.id, "dark_theme?" => user.dark_theme? }]
expect(decorated_collection.as_json(only: [:id], methods: [:dark_theme?])).to eq(expected_result)
end
end
describe "#cached_followed_tags" do
let_it_be(:user) { create(:user) }
let(:tag1) { create(:tag) }
let(:tag2) { create(:tag) }
let(:tag3) { create(:tag) }
it "returns empty if no tags followed" do
expect(user.decorate.cached_followed_tags.size).to eq(0)
expect(saved_user.decorate.cached_followed_tags.size).to eq(0)
end
it "returns array of tags if user follows them" do
user.follow(tag1)
user.follow(tag2)
user.follow(tag3)
expect(user.decorate.cached_followed_tags.size).to eq(3)
saved_user.follow(tag1)
saved_user.follow(tag2)
saved_user.follow(tag3)
expect(saved_user.decorate.cached_followed_tags.size).to eq(3)
end
it "returns tag object with name" do
user.follow(tag1)
expect(user.decorate.cached_followed_tags.first.name).to eq(tag1.name)
saved_user.follow(tag1)
expect(saved_user.decorate.cached_followed_tags.first.name).to eq(tag1.name)
end
it "returns follow points for tag" do
user.follow(tag1)
expect(user.decorate.cached_followed_tags.first.points).to eq(1.0)
saved_user.follow(tag1)
expect(saved_user.decorate.cached_followed_tags.first.points).to eq(1.0)
end
it "returns adjusted points for tag" do
follow = user.follow(tag1)
follow = saved_user.follow(tag1)
follow.update(points: 0.1)
expect(user.decorate.cached_followed_tags.first.points).to eq(0.1)
expect(saved_user.decorate.cached_followed_tags.first.points).to eq(0.1)
end
end
describe "#darker_color" do
it "returns a darker version of the assigned color if colors are blank" do
saved_user.assign_attributes(bg_color_hex: "", text_color_hex: "")
expect(saved_user.decorate.darker_color).to be_present
end
it "returns not fully banished if in good standing" do
expect(user.decorate.fully_banished?).to eq(false)
it "returns a darker version of the color if bg_color_hex is present" do
saved_user.assign_attributes(bg_color_hex: "#dddddd", text_color_hex: "#ffffff")
expect(saved_user.decorate.darker_color).to eq("#c2c2c2")
end
it "returns fully banished if user has been banished" do
Moderator::BanishUser.call(admin: user, user: user)
expect(user.decorate.fully_banished?).to eq(true)
it "returns an adjusted darker version of the color" do
saved_user.assign_attributes(bg_color_hex: "#dddddd", text_color_hex: "#ffffff")
expect(saved_user.decorate.darker_color(0.3)).to eq("#424242")
end
it "returns an adjusted lighter version of the color if adjustment is over 1.0" do
saved_user.assign_attributes(bg_color_hex: "#dddddd", text_color_hex: "#ffffff")
expect(saved_user.decorate.darker_color(1.1)).to eq("#f3f3f3")
end
end
describe "#enriched_colors" do
it "returns assigned colors if bg_color_hex is blank" do
saved_user.assign_attributes(bg_color_hex: "")
expect(saved_user.decorate.enriched_colors[:bg]).to be_present
expect(saved_user.decorate.enriched_colors[:text]).to be_present
end
it "returns assigned colors if text_color_hex is blank" do
saved_user.assign_attributes(text_color_hex: "")
expect(saved_user.decorate.enriched_colors[:bg]).to be_present
expect(saved_user.decorate.enriched_colors[:text]).to be_present
end
it "returns bg_color_hex and assigned text_color_hex if text_color_hex is blank" do
saved_user.assign_attributes(bg_color_hex: "#dddddd", text_color_hex: "")
expect(saved_user.decorate.enriched_colors[:bg]).to be_present
expect(saved_user.decorate.enriched_colors[:text]).to be_present
end
it "returns text_color_hex and assigned bg_color_hex if bg_color_hex is blank" do
saved_user.assign_attributes(bg_color_hex: "", text_color_hex: "#ffffff")
expect(saved_user.decorate.enriched_colors[:bg]).to be_present
expect(saved_user.decorate.enriched_colors[:text]).to be_present
end
it "returns bg_color_hex and text_color_hex if both are present" do
saved_user.assign_attributes(bg_color_hex: "#dddddd", text_color_hex: "#fffff3")
expect(saved_user.decorate.enriched_colors).to eq(bg: "#dddddd", text: "#fffff3")
end
end
@ -141,4 +200,26 @@ RSpec.describe UserDecorator, type: :decorator do
expect(user.decorate.dark_theme?).to be(false)
end
end
describe "#fully_banished?" do
it "returns not fully banished if in good standing" do
expect(user.decorate.fully_banished?).to eq(false)
end
it "returns fully banished if user has been banished" do
Moderator::BanishUser.call(admin: user, user: user)
expect(user.decorate.fully_banished?).to eq(true)
end
end
describe "#stackbit_integration?" do
it "returns false by default" do
expect(user.decorate.stackbit_integration?).to be(false)
end
it "returns true if the user has access tokens" do
user.access_tokens.build
expect(user.decorate.stackbit_integration?).to be(true)
end
end
end

View file

@ -7,4 +7,35 @@ RSpec.describe ApplicationRecord, type: :model do
expect { User.estimated_count }.not_to raise_error
end
end
describe "#decorate" do
it "decorates an object that has a decorator" do
sponsorship = build(:sponsorship)
expect(sponsorship.decorate).to be_a(SponsorshipDecorator)
end
it "raises an error if an object has no decorator" do
badge = build(:badge)
expect { badge.decorate }.to raise_error(UninferrableDecoratorError)
end
end
describe "#decorated?" do
it "returns false" do
sponsorship = build(:sponsorship)
expect(sponsorship.decorated?).to be(false)
end
end
describe ".decorate" do
before do
create(:sponsorship, level: :gold)
end
it "decorates a relation" do
decorated_collection = Sponsorship.gold.decorate
expect(decorated_collection.size).to eq(Sponsorship.gold.size)
expect(decorated_collection.first).to be_a(SponsorshipDecorator)
end
end
end

View file

@ -2,6 +2,7 @@ require "rails_helper"
RSpec.describe "ArticlesCreate", type: :request do
let(:user) { create(:user, :org_member) }
let(:template) { file_fixture("article_published.txt").read }
before do
sign_in user
@ -48,6 +49,15 @@ RSpec.describe "ArticlesCreate", type: :request do
expect(Collection.last.slug).to eq("helloyo")
end
it "returns the ID and the current_state_path of the article" do
post "/articles", params: { article: { body_markdown: template } }
expect(response).to have_http_status(:ok)
article = Article.last
expect(response.parsed_body["id"]).to eq(article.id)
expect(response.parsed_body["current_state_path"]).to eq(article.current_state_path)
end
context "when scheduling jobs" do
let(:url) { Faker::Internet.url(scheme: "https") }
let(:article_params) do

View file

@ -76,33 +76,38 @@ RSpec.describe "StoriesIndex", type: :request do
end
context "with campaign hero" do
let!(:hero_html) { create(:html_variant, group: "campaign", name: "hero", html: Faker::Book.title, published: true, approved: true) }
let!(:hero_html) do
create(:html_variant, group: "campaign", name: "hero", html: Faker::Book.title, published: true, approved: true)
end
it "displays hero html when it exists and is set in config" do
SiteConfig.campaign_hero_html_variant_name = "hero"
get "/"
expect(response.body).to include(CGI.escapeHTML(hero_html.html))
end
end
it "doesn't display when campaign_hero_html_variant_name is not set" do
it "doesn't display when campaign_hero_html_variant_name is not set" do
SiteConfig.campaign_hero_html_variant_name = ""
get "/"
expect(response.body).not_to include(CGI.escapeHTML(hero_html.html))
end
end
it "doesn't display when hero html is not approved" do
it "doesn't display when hero html is not approved" do
SiteConfig.campaign_hero_html_variant_name = "hero"
hero_html.update_column(:approved, false)
get "/"
expect(response.body).not_to include(CGI.escapeHTML(hero_html.html))
end
end
end
end
context "with campaign_sidebar" do
context "with campaign_sidebar" do
before do
SiteConfig.campaign_featured_tags = "shecoded,theycoded"
create(:article, approved: true, body_markdown: "---\ntitle: Super-sheep#{rand(1000)}\npublished: true\ntags: heyheyhey,shecoded\n---\n\nHello")
create(:article, approved: false, body_markdown: "---\ntitle: Unapproved-post#{rand(1000)}\npublished: true\ntags: heyheyhey,shecoded\n---\n\nHello")
a_body = "---\ntitle: Super-sheep#{rand(1000)}\npublished: true\ntags: heyheyhey,shecoded\n---\n\nHello"
create(:article, approved: true, body_markdown: a_body)
u_body = "---\ntitle: Unapproved-post#{rand(1000)}\npublished: true\ntags: heyheyhey,shecoded\n---\n\nHello"
create(:article, approved: false, body_markdown: u_body)
end
it "doesn't display posts with the campaign tags when sidebar is disabled" do
@ -123,6 +128,31 @@ RSpec.describe "StoriesIndex", type: :request do
expect(response.body).not_to include(CGI.escapeHTML("Super-puper"))
end
end
describe "when authenticated" do
let(:user) { create(:user) }
before do
sign_in user
end
it "contains the stories correctly serialized" do
# we control titles to avoid escaping errors with apostrophes and such
article = create(:article, featured: true)
article.update_columns(title: "abc")
articles = create_list(:article, 2)
articles.each { |a| a.update_columns(title: "abc") }
get root_path
expect(response).to have_http_status(:ok)
stories = controller.instance_variable_get(:@stories) # cheating a bit ;)
expected_result = ArticleDecorator.decorate_collection(stories).
to_json(controller.class.const_get(:DEFAULT_HOME_FEED_ATTRIBUTES_FOR_SERIALIZATION))
expect(response.body).to include(ERB::Util.html_escape(expected_result))
end
end
end
describe "GET query page" do
@ -143,6 +173,19 @@ RSpec.describe "StoriesIndex", type: :request do
describe "GET tag index" do
let(:tag) { create(:tag) }
let(:org) { create(:organization) }
def create_live_sponsor(org, tag)
create(
:sponsorship,
level: :tag,
blurb_html: "<p>Oh Yeah!!!</p>",
status: "live",
organization: org,
sponsorable: tag,
expires_at: 30.days.from_now,
)
end
it "renders page with proper header" do
get "/t/#{tag.name}"
@ -167,8 +210,9 @@ RSpec.describe "StoriesIndex", type: :request do
end
it "does not render sponsor if not live" do
org = create(:organization)
sponsorship = create(:sponsorship, level: :tag, tagline: "Oh Yeah!!!", status: "pending", organization: org, sponsorable: tag)
sponsorship = create(
:sponsorship, level: :tag, tagline: "Oh Yeah!!!", status: "pending", organization: org, sponsorable: tag
)
get "/t/#{tag.name}"
expect(response.body).not_to include("is sponsored by")
@ -176,8 +220,7 @@ RSpec.describe "StoriesIndex", type: :request do
end
it "renders live sponsor" do
org = create(:organization)
sponsorship = create(:sponsorship, level: :tag, blurb_html: "<p>Oh Yeah!!!</p>", status: "live", organization: org, sponsorable: tag, expires_at: 30.days.from_now)
sponsorship = create_live_sponsor(org, tag)
get "/t/#{tag.name}"
expect(response.body).to include("is sponsored by")
expect(response.body).to include(sponsorship.blurb_html)

View file

@ -12,9 +12,10 @@ RSpec.describe Articles::Creator, type: :service do
end.to change(Article, :count).by(1)
end
it "returns an article" do
it "returns a non decorated, persisted article" do
article = described_class.call(user, valid_attributes)
expect(article).to be_kind_of(Article)
expect(article.decorated?).to be(false)
expect(article).to be_persisted
end
@ -35,7 +36,7 @@ RSpec.describe Articles::Creator, type: :service do
event_dispatcher = double
allow(event_dispatcher).to receive(:call)
article = described_class.call(user, valid_attributes, event_dispatcher)
expect(event_dispatcher).to have_received(:call).with("article_created", article.object)
expect(event_dispatcher).to have_received(:call).with("article_created", article)
end
it "doesn't call an event dispatcher when an article is unpublished" do
@ -43,7 +44,7 @@ RSpec.describe Articles::Creator, type: :service do
event_dispatcher = double
allow(event_dispatcher).to receive(:call)
article = described_class.call(user, attributes, event_dispatcher)
expect(event_dispatcher).not_to have_received(:call).with("article_created", article.object)
expect(event_dispatcher).not_to have_received(:call).with("article_created", article)
end
end
@ -60,9 +61,10 @@ RSpec.describe Articles::Creator, type: :service do
end.not_to change(Article, :count)
end
it "returns an unsaved article" do
it "returns a non decorated, non persisted article" do
article = described_class.call(user, invalid_attributes)
expect(article).to be_kind_of(Article)
expect(article.decorated?).to be(false)
expect(article).not_to be_persisted
expect(article.errors.size).to eq(1)
end