From b2d24f291b040d49fd8f994bedc3dc73403f0d1b Mon Sep 17 00:00:00 2001 From: Ridhwana Date: Thu, 3 Nov 2022 15:51:51 +0200 Subject: [PATCH] Display Ads: Target by Tag on Posts with matching Tag(s) on the user facing side (#18664) * feat: Add a pack file that pulls in the MultiSelect Component * feat: move the tags to its own component * save tags * refactor: create a getCSRFToken function in the packs files so that it can be used in the admin * feat: import the new module in request.js * feat: remove unnecessary id * feat: first pass of csrf token test * chore: update the test * fix: csrf token * feat: hide the enw functionality behinda feature flag * fix: loading form twice * refactor: import for csrftoken * chore: update the description of the function * feat: use acts_on_taggable to craete a relationship between display_ad and tag * feat: add a tag field to the display_ad form * feat: add the selected tags from the multiselect autocomplete component to the input text field that references the tag_list * feat: add the tag_list to the controller so that we can save it to the db with the display ad parameters * feat: pull out the tag validation from the article and the display_ads into a concern * feat: write soem tests for validating the tag on the display_ads model * feat: add the tag_list as a hidden field * feat: set the selected tags on edit * feat: add a js class for the placement area * feat: use the change in the dropdown to determine whether we show the tags field dropdown * refactor: rename and brak up fucntions * feat: show the tags field if the value of the placement area is already set * fix: move if statement out of the change event * feat: hide tags field and clear the tag list * refactor: delete the display ads * tests: ensure that we test the toggle * fix:ensure that the tags on the hidden field show up as a string in the input field * feat: update the jsdoc * fix: no need to replace space with comma since we showing as string in form * feat: update the name of the label * fix: add hidden back * feat: add cached_tag_list to displya_ads + the index * feat: udpdate the schema * refactor: move the scopes into the a module that can be re-used between display_ads and articles * feat: account for showing display ads with the targeted tags that match the article * feat: write a spec to show the correct ads based on the article_tags * refactor: change TagListValidateable to Taggable and move out the not_cached_tagged_with_any scope * test teh relationship between display ads and tags * feat: write soem tests for validating the tag on the display_ads model * chore: change empty to blank * feat: will show display ads with no tags set if there are no article tags * feat: add a newline --- app/models/article.rb | 43 +--- app/models/concerns/tag_list_validateable.rb | 11 - app/models/concerns/taggable.rb | 57 +++++ app/models/display_ad.rb | 15 +- app/views/articles/show.html.erb | 2 +- ...3257_add_cached_tag_list_to_display_ads.rb | 5 + ...dd_index_to_display_ads_cached_tag_list.rb | 9 + db/schema.rb | 4 +- spec/factories/articles.rb | 12 +- spec/models/article_spec.rb | 205 +---------------- spec/models/display_ad_spec.rb | 65 ++++++ spec/models/shared_examples/taggable_spec.rb | 208 ++++++++++++++++++ 12 files changed, 374 insertions(+), 262 deletions(-) delete mode 100644 app/models/concerns/tag_list_validateable.rb create mode 100644 app/models/concerns/taggable.rb create mode 100644 db/migrate/20221031133257_add_cached_tag_list_to_display_ads.rb create mode 100644 db/migrate/20221031133328_add_index_to_display_ads_cached_tag_list.rb create mode 100644 spec/models/shared_examples/taggable_spec.rb diff --git a/app/models/article.rb b/app/models/article.rb index 7885169fe..a87702346 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -2,7 +2,7 @@ class Article < ApplicationRecord include CloudinaryHelper include ActionView::Helpers include Reactable - include TagListValidateable + include Taggable include UserSubscriptionSourceable include PgSearch::Model @@ -288,47 +288,6 @@ class Article < ApplicationRecord .tagged_with(tag_name) } - scope :cached_tagged_with, lambda { |tag| - case tag - when String, Symbol - # In Postgres regexes, the [[:<:]] and [[:>:]] are equivalent to "start of - # word" and "end of word", respectively. They're similar to `\b` in Perl- - # compatible regexes (PCRE), but that matches at either end of a word. - # They're more comparable to how vim's `\<` and `\>` work. - where("cached_tag_list ~ ?", "[[:<:]]#{tag}[[:>:]]") - when Array - tag.reduce(self) { |acc, elem| acc.cached_tagged_with(elem) } - when Tag - cached_tagged_with(tag.name) - else - raise TypeError, "Cannot search tags for: #{tag.inspect}" - end - } - - scope :cached_tagged_with_any, lambda { |tags| - case tags - when String, Symbol - cached_tagged_with(tags) - when Array - tags - .map { |tag| cached_tagged_with(tag) } - .reduce { |acc, elem| acc.or(elem) } - when Tag - cached_tagged_with(tags.name) - else - raise TypeError, "Cannot search tags for: #{tags.inspect}" - end - } - - # We usually try to avoid using Arel directly like this. However, none of the more - # straight-forward ways of negating the above scope worked: - # 1. A subquery doesn't work because we're not dealing with a simple NOT IN scenario. - # 2. where.not(cached_tagged_with_any(tags).where_values_hash) doesn't work because where_values_hash - # only works for simple conditions and returns an empty hash in this case. - scope :not_cached_tagged_with_any, lambda { |tags| - where(cached_tagged_with_any(tags).arel.constraints.reduce(:or).not) - } - scope :active_help, lambda { stories = published.cached_tagged_with("help").order(created_at: :desc) diff --git a/app/models/concerns/tag_list_validateable.rb b/app/models/concerns/tag_list_validateable.rb deleted file mode 100644 index 433bee608..000000000 --- a/app/models/concerns/tag_list_validateable.rb +++ /dev/null @@ -1,11 +0,0 @@ -module TagListValidateable - extend ActiveSupport::Concern - # we check tags names aren't too long and don't contain non alphabet characters - def validate_tag_name(tag_list) - tag_list.each do |tag| - new_tag = Tag.new(name: tag) - new_tag.validate_name - new_tag.errors.messages[:name].each { |message| errors.add(:tag, "\"#{tag}\" #{message}") } - end - end -end diff --git a/app/models/concerns/taggable.rb b/app/models/concerns/taggable.rb new file mode 100644 index 000000000..8f2a18a3e --- /dev/null +++ b/app/models/concerns/taggable.rb @@ -0,0 +1,57 @@ +module Taggable + extend ActiveSupport::Concern + + # rubocop:disable Metrics/BlockLength(RuboCop) + included do + scope :cached_tagged_with, lambda { |tag| + case tag + when String, Symbol + # In Postgres regexes, the [[:<:]] and [[:>:]] are equivalent to "start of + # word" and "end of word", respectively. They're similar to `\b` in Perl- + # compatible regexes (PCRE), but that matches at either end of a word. + # They're more comparable to how vim's `\<` and `\>` work. + where("cached_tag_list ~ ?", "[[:<:]]#{tag}[[:>:]]") + when Array + tag.reduce(self) { |acc, elem| acc.cached_tagged_with(elem) } + when Tag + cached_tagged_with(tag.name) + else + raise TypeError, "Cannot search tags for: #{tag.inspect}" + end + } + + scope :cached_tagged_with_any, lambda { |tags| + case tags + when String, Symbol + cached_tagged_with(tags) + when Array + tags + .map { |tag| cached_tagged_with(tag) } + .reduce { |acc, elem| acc.or(elem) } + when Tag + cached_tagged_with(tags.name) + else + raise TypeError, "Cannot search tags for: #{tags.inspect}" + end + } + + # We usually try to avoid using Arel directly like this. However, none of the more + # straight-forward ways of negating the above scope worked: + # 1. A subquery doesn't work because we're not dealing with a simple NOT IN scenario. + # 2. where.not(cached_tagged_with_any(tags).where_values_hash) doesn't work because where_values_hash + # only works for simple conditions and returns an empty hash in this case. + scope :not_cached_tagged_with_any, lambda { |tags| + where(cached_tagged_with_any(tags).arel.constraints.reduce(:or).not) + } + end + # rubocop:enable Metrics/BlockLength(RuboCop) + + # we check tags names aren't too long and don't contain non alphabet characters + def validate_tag_name(tag_list) + tag_list.each do |tag| + new_tag = Tag.new(name: tag) + new_tag.validate_name + new_tag.errors.messages[:name].each { |message| errors.add(:tag, "\"#{tag}\" #{message}") } + end + end +end diff --git a/app/models/display_ad.rb b/app/models/display_ad.rb index 8ce62b302..f047958b2 100644 --- a/app/models/display_ad.rb +++ b/app/models/display_ad.rb @@ -1,5 +1,5 @@ class DisplayAd < ApplicationRecord - include TagListValidateable + include Taggable acts_as_taggable_on :tags resourcify @@ -32,9 +32,20 @@ class DisplayAd < ApplicationRecord search: "%#{term}%" } - def self.for_display(area, user_signed_in) + def self.for_display(area, user_signed_in, article_tags = []) relation = approved_and_published.where(placement_area: area).order(success_rate: :desc) + if article_tags.any? + display_ads_with_no_tags = relation.where(cached_tag_list: "") + display_ads_with_targeted_article_tags = relation.cached_tagged_with_any(article_tags) + + relation = display_ads_with_no_tags.or(display_ads_with_targeted_article_tags) + end + + if article_tags.blank? + relation = relation.where(cached_tag_list: "") + end + relation = if user_signed_in relation.where(display_to: %w[all logged_in]) else diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb index 8edec99de..2f2d0ef96 100644 --- a/app/views/articles/show.html.erb +++ b/app/views/articles/show.html.erb @@ -191,7 +191,7 @@ <% cache("article-bottom-content-#{@article.id}-#{user_signed_in?}-#{(@organization || @user).latest_article_updated_at}", expires_in: 18.hours) do %> - <% @display_ad = DisplayAd.for_display("post_comments", user_signed_in?) %> + <% @display_ad = DisplayAd.for_display("post_comments", user_signed_in?, @article.decorate.cached_tag_list_array) %> <% if @display_ad %>
+ # We need a GIN index on `cached_tag_list` to speed up `LIKE` queries + add_index :display_ads, :cached_tag_list, using: :gin, opclass: :gin_trgm_ops, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 9c80404da..494444375 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_10_20_120850) do +ActiveRecord::Schema[7.0].define(version: 2022_10_31_133328) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_stat_statements" @@ -456,6 +456,7 @@ ActiveRecord::Schema[7.0].define(version: 2022_10_20_120850) do create_table "display_ads", force: :cascade do |t| t.boolean "approved", default: false t.text "body_markdown" + t.string "cached_tag_list" t.integer "clicks_count", default: 0 t.datetime "created_at", precision: nil, null: false t.integer "display_to", default: 0, null: false @@ -467,6 +468,7 @@ ActiveRecord::Schema[7.0].define(version: 2022_10_20_120850) do t.boolean "published", default: false t.float "success_rate", default: 0.0 t.datetime "updated_at", precision: nil, null: false + t.index ["cached_tag_list"], name: "index_display_ads_on_cached_tag_list", opclass: :gin_trgm_ops, using: :gin end create_table "email_authorizations", force: :cascade do |t| diff --git a/spec/factories/articles.rb b/spec/factories/articles.rb index 569641bef..0f2ed014d 100644 --- a/spec/factories/articles.rb +++ b/spec/factories/articles.rb @@ -21,6 +21,7 @@ FactoryBot.define do with_collection { nil } past_published_at { Time.current } end + co_author_ids { [] } association :user, factory: :user, strategy: :create description { Faker::Hipster.paragraph(sentence_count: 1)[0..100] } @@ -29,13 +30,22 @@ FactoryBot.define do URL.url(ActionController::Base.helpers.asset_path("#{rand(1..40)}.png")) end end + experience_level_rating { rand(4..6) } + # The tags property in the markdown is a bit of a hack, and this entire factory needs refactoring. + # In the Tagglable spec we want to extract some common scopes from the article and display ad + # models and test them, hence we want to pass through the tag_list property. + # However, the body_markdown caters for the way that we associate tags for the v1 editor. + # Hence, in this test we default to the transient with_tags being set to true, but if we pass a tag_list through + # then we're making the assumption that it is the v2 editor and we do not want the tags on the body markdown. + # Ideally, we want to create a completely different body_markdown without the frontmatter depending on the version + # of the editor since we pass through different JSON based on the editor. body_markdown do <<~HEREDOC --- title: #{title if with_title} published: #{published} - tags: #{tags if with_tags} + #{"tags: #{tags}" if with_tags && tag_list.blank?} date: #{date if with_date} series: #{with_collection&.slug} canonical_url: #{canonical_url if with_canonical_url} diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index d472dfe59..0b956d314 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -12,6 +12,7 @@ RSpec.describe Article, type: :model do include_examples "#sync_reactions_count", :article it_behaves_like "UserSubscriptionSourceable" + it_behaves_like "Taggable" describe "validations" do it { is_expected.to belong_to(:collection).optional } @@ -987,210 +988,6 @@ RSpec.describe Article, type: :model do end end - describe ".cached_tagged_with" do - it "can search for a single tag" do - included = create(:article, tags: "includeme") - excluded = create(:article, tags: "lol, nope") - - articles = described_class.cached_tagged_with("includeme") - - expect(articles).to include included - expect(articles).not_to include excluded - expect(articles.to_a).to eq described_class.tagged_with("includeme").to_a - end - - it "can search for a single tag when given a symbol" do - included = create(:article, tags: "includeme") - excluded = create(:article, tags: "lol, nope") - - articles = described_class.cached_tagged_with(:includeme) - - expect(articles).to include(included) - expect(articles).not_to include(excluded) - expect(articles.to_a).to eq(described_class.tagged_with("includeme").to_a) - end - - it "can search for a single tag when given a Tag object" do - included = create(:article, tags: "includeme") - excluded = create(:article, tags: "lol, nope") - - tag = Tag.find_by(name: :includeme) - - articles = described_class.cached_tagged_with(tag) - - expect(articles).to include included - expect(articles).not_to include excluded - expect(articles.to_a).to eq described_class.tagged_with("includeme").to_a - end - - it "can search among multiple tags" do - included = [ - create(:article, tags: "omg, wtf"), - create(:article, tags: "omg, lol"), - ] - excluded = create(:article, tags: "nope, excluded") - - articles = described_class.cached_tagged_with("omg") - - expect(articles).to include(*included) - expect(articles).not_to include excluded - expect(articles.to_a).to include(*described_class.tagged_with("omg").to_a) - end - - it "can search for multiple tags" do - included = create(:article, tags: "includeme, please, lol") - excluded_partial_match = create(:article, tags: "excluded, please") - excluded_no_match = create(:article, tags: "excluded, omg") - - articles = described_class.cached_tagged_with(%w[includeme please]) - - expect(articles).to include included - expect(articles).not_to include excluded_partial_match - expect(articles).not_to include excluded_no_match - expect(articles.to_a).to eq described_class.tagged_with(%w[includeme please]).to_a - end - - it "can search for multiple tags passed as an array of symbols" do - included = create(:article, tags: "includeme, please, lol") - excluded_partial_match = create(:article, tags: "excluded, please") - excluded_no_match = create(:article, tags: "excluded, omg") - - articles = described_class.cached_tagged_with(%i[includeme please]) - - expect(articles).to include(included) - expect(articles).not_to include(excluded_partial_match) - expect(articles).not_to include(excluded_no_match) - expect(articles.to_a).to eq(described_class.tagged_with(%i[includeme please]).to_a) - end - - it "can search for multiple tags passed as an array of Tag objects" do - included = create(:article, tags: "includeme, please, lol") - excluded_partial_match = create(:article, tags: "excluded, please") - excluded_no_match = create(:article, tags: "excluded, omg") - - tags = Tag.where(name: %i[includeme please]).to_a - articles = described_class.cached_tagged_with(tags) - - expect(articles).to include(included) - expect(articles).not_to include(excluded_partial_match) - expect(articles).not_to include(excluded_no_match) - expect(articles.to_a).to eq(described_class.tagged_with(%i[includeme please]).to_a) - end - end - - describe ".cached_tagged_with_any" do - it "can search for a single tag" do - included = create(:article, tags: "includeme") - excluded = create(:article, tags: "lol, nope") - - articles = described_class.cached_tagged_with_any("includeme") - - expect(articles).to include included - expect(articles).not_to include excluded - expect(articles.to_a).to eq described_class.tagged_with("includeme", any: true).to_a - end - - it "can search for a single tag when given a symbol" do - included = create(:article, tags: "includeme") - excluded = create(:article, tags: "lol, nope") - - articles = described_class.cached_tagged_with_any(:includeme) - - expect(articles).to include(included) - expect(articles).not_to include(excluded) - expect(articles.to_a).to eq(described_class.tagged_with("includeme", any: true).to_a) - end - - it "can search for a single tag when given a Tag object" do - included = create(:article, tags: "includeme") - excluded = create(:article, tags: "lol, nope") - - tag = Tag.find_by(name: :includeme) - articles = described_class.cached_tagged_with_any(tag) - - expect(articles).to include(included) - expect(articles).not_to include(excluded) - expect(articles.to_a).to eq(described_class.tagged_with("includeme", any: true).to_a) - end - - it "can search among multiple tags" do - included = [ - create(:article, tags: "omg, wtf"), - create(:article, tags: "omg, lol"), - ] - excluded = create(:article, tags: "nope, excluded") - - articles = described_class.cached_tagged_with_any("omg") - expected = described_class.tagged_with("omg", any: true).to_a - - expect(articles).to include(*included) - expect(articles).not_to include excluded - expect(articles.to_a).to include(*expected) - end - - it "can search for multiple tags" do - included = create(:article, tags: "includeme, please, lol") - included_partial_match = create(:article, tags: "includeme, omg") - excluded_no_match = create(:article, tags: "excluded, omg") - - articles = described_class.cached_tagged_with_any(%w[includeme please]) - expected = described_class.tagged_with(%w[includeme please], any: true).to_a - - expect(articles).to include included - expect(articles).to include included_partial_match - expect(articles).not_to include excluded_no_match - - expect(articles.to_a).to include(*expected) - end - - it "can search for multiple tags when given an array of symbols" do - included = create(:article, tags: "includeme, please, lol") - included_partial_match = create(:article, tags: "includeme, omg") - excluded_no_match = create(:article, tags: "excluded, omg") - - articles = described_class.cached_tagged_with_any(%i[includeme please]) - expected = described_class.tagged_with(%i[includeme please], any: true).to_a - - expect(articles).to include(included) - expect(articles).to include(included_partial_match) - expect(articles).not_to include(excluded_no_match) - - expect(articles.to_a).to include(*expected) - end - - it "can search for multiple tags when given an array of Tag objects" do - included = create(:article, tags: "includeme, please, lol") - included_partial_match = create(:article, tags: "includeme, omg") - excluded_no_match = create(:article, tags: "excluded, omg") - - tags = Tag.where(name: %i[includeme please]).to_a - articles = described_class.cached_tagged_with_any(tags) - expected = described_class.tagged_with(%i[includeme please], any: true).to_a - - expect(articles).to include(included) - expect(articles).to include(included_partial_match) - expect(articles).not_to include(excluded_no_match) - - expect(articles.to_a).to include(*expected) - end - end - - describe ".not_cached_tagged_with_any" do - it "can exclude multiple tags when given an array of strings" do - included = create(:article, tags: "includeme") - excluded1 = create(:article, tags: "includeme, lol") - excluded2 = create(:article, tags: "includeme, omg") - - articles = described_class - .cached_tagged_with_any("includeme") - .not_cached_tagged_with_any(%w[lol omg]) - - expect(articles).to include included - expect(articles).not_to include excluded1 - expect(articles).not_to include excluded2 - end - end - context "when callbacks are triggered before save" do it "assigns path on save" do expect(article.path).to eq("/#{article.username}/#{article.slug}") diff --git a/spec/models/display_ad_spec.rb b/spec/models/display_ad_spec.rb index c2a78982f..fe4ed5df7 100644 --- a/spec/models/display_ad_spec.rb +++ b/spec/models/display_ad_spec.rb @@ -4,6 +4,8 @@ RSpec.describe DisplayAd, type: :model do let(:organization) { create(:organization) } let(:display_ad) { create(:display_ad, organization_id: organization.id) } + it_behaves_like "Taggable" + describe "validations" do describe "builtin validations" do subject { display_ad } @@ -13,6 +15,7 @@ RSpec.describe DisplayAd, type: :model do it { is_expected.to validate_presence_of(:placement_area) } it { is_expected.to validate_presence_of(:body_markdown) } + it { is_expected.to have_many(:tags) } end it "allows sidebar_right" do @@ -82,6 +85,68 @@ RSpec.describe DisplayAd, type: :model do end end + context "when considering article_tags" do + it "will show the display ads that contain tags that match any of the article tags" do + display_ad = create(:display_ad, organization_id: organization.id, + placement_area: "post_comments", + published: true, + approved: true, + cached_tag_list: "linux, git, go") + + create(:display_ad, organization_id: organization.id, + placement_area: "post_comments", + published: true, + approved: true, + cached_tag_list: "career") + + article_tags = %w[linux productivity] + expect(described_class.for_display("post_comments", false, article_tags)).to eq(display_ad) + end + + it "will show display ads that have no tags set" do + display_ad = create(:display_ad, organization_id: organization.id, + placement_area: "post_comments", + published: true, + approved: true, + cached_tag_list: "") + + create(:display_ad, organization_id: organization.id, + placement_area: "post_comments", + published: true, + approved: true, + cached_tag_list: "career") + + article_tags = %w[productivity java] + expect(described_class.for_display("post_comments", false, article_tags)).to eq(display_ad) + end + + it "will show no display ads if the available display ads have no tags set or do not contain matching tags" do + create(:display_ad, organization_id: organization.id, + placement_area: "post_comments", + published: true, + approved: true, + cached_tag_list: "productivity") + article_tags = %w[javascript] + expect(described_class.for_display("post_comments", false, article_tags)).to be_nil + end + + it "will show display ads with no tags set if there are no article tags" do + create(:display_ad, organization_id: organization.id, + placement_area: "post_comments", + published: true, + approved: true, + cached_tag_list: "productivity") + + display_ad_without_tags = create(:display_ad, organization_id: organization.id, + placement_area: "post_comments", + published: true, + approved: true, + cached_tag_list: "") + + expect(described_class.for_display("post_comments", false)).to eq(display_ad_without_tags) + end + end + context "when display_to is set to 'logged_in' or 'logged_out'" do let!(:display_ad2) do create(:display_ad, organization_id: organization.id, published: true, approved: true, display_to: "logged_in") diff --git a/spec/models/shared_examples/taggable_spec.rb b/spec/models/shared_examples/taggable_spec.rb new file mode 100644 index 000000000..51a2ad984 --- /dev/null +++ b/spec/models/shared_examples/taggable_spec.rb @@ -0,0 +1,208 @@ +RSpec.shared_examples "Taggable" do + let(:model) { described_class } # the class that includes the concern + object = described_class.name.underscore.to_sym + + describe ".cached_tagged_with" do + it "can search for a single tag" do + included = create(object, tag_list: "includeme") + excluded = create(object, tag_list: "lol, nope") + + articles = described_class.cached_tagged_with("includeme") + + expect(articles).to include included + expect(articles).not_to include excluded + expect(articles.to_a).to eq described_class.tagged_with("includeme").to_a + end + + it "can search for a single tag when given a symbol" do + included = create(object, tag_list: "includeme") + excluded = create(object, tag_list: "lol, nope") + + articles = described_class.cached_tagged_with(:includeme) + + expect(articles).to include(included) + expect(articles).not_to include(excluded) + expect(articles.to_a).to eq(described_class.tagged_with("includeme").to_a) + end + + it "can search for a single tag when given a Tag object" do + included = create(object, tag_list: "includeme") + excluded = create(object, tag_list: "lol, nope") + + tag = Tag.find_by(name: :includeme) + + articles = described_class.cached_tagged_with(tag) + + expect(articles).to include included + expect(articles).not_to include excluded + expect(articles.to_a).to eq described_class.tagged_with("includeme").to_a + end + + it "can search among multiple tags" do + included = [ + create(object, tag_list: "omg, wtf"), + create(object, tag_list: "omg, lol"), + ] + excluded = create(object, tag_list: "nope, excluded") + + articles = described_class.cached_tagged_with("omg") + + expect(articles).to include(*included) + expect(articles).not_to include excluded + expect(articles.to_a).to include(*described_class.tagged_with("omg").to_a) + end + + it "can search for multiple tags" do + included = create(object, tag_list: "includeme, please, lol") + excluded_partial_match = create(object, tag_list: "excluded, please") + excluded_no_match = create(object, tag_list: "excluded, omg") + + articles = described_class.cached_tagged_with(%w[includeme please]) + + expect(articles).to include included + expect(articles).not_to include excluded_partial_match + expect(articles).not_to include excluded_no_match + expect(articles.to_a).to eq described_class.tagged_with(%w[includeme please]).to_a + end + + it "can search for multiple tags passed as an array of symbols" do + included = create(object, tag_list: "includeme, please, lol") + excluded_partial_match = create(object, tag_list: "excluded, please") + excluded_no_match = create(object, tag_list: "excluded, omg") + + articles = described_class.cached_tagged_with(%i[includeme please]) + + expect(articles).to include(included) + expect(articles).not_to include(excluded_partial_match) + expect(articles).not_to include(excluded_no_match) + expect(articles.to_a).to eq(described_class.tagged_with(%i[includeme please]).to_a) + end + + it "can search for multiple tags passed as an array of Tag objects" do + included = create(object, tag_list: "includeme, please, lol") + excluded_partial_match = create(object, tag_list: "excluded, please") + excluded_no_match = create(object, tag_list: "excluded, omg") + + tags = Tag.where(name: %i[includeme please]).to_a + articles = described_class.cached_tagged_with(tags) + + expect(articles).to include(included) + expect(articles).not_to include(excluded_partial_match) + expect(articles).not_to include(excluded_no_match) + expect(articles.to_a).to eq(described_class.tagged_with(%i[includeme please]).to_a) + end + end + + describe ".cached_tagged_with_any" do + it "can search for a single tag" do + included = create(object, tag_list: "includeme") + excluded = create(object, tag_list: "lol, nope") + + articles = described_class.cached_tagged_with_any("includeme") + + expect(articles).to include included + expect(articles).not_to include excluded + expect(articles.to_a).to eq described_class.tagged_with("includeme", any: true).to_a + end + + it "can search for a single tag when given a symbol" do + included = create(object, tag_list: "includeme") + excluded = create(object, tag_list: "lol, nope") + + articles = described_class.cached_tagged_with_any(:includeme) + + expect(articles).to include(included) + expect(articles).not_to include(excluded) + expect(articles.to_a).to eq(described_class.tagged_with("includeme", any: true).to_a) + end + + it "can search for a single tag when given a Tag object" do + included = create(object, tag_list: "includeme") + excluded = create(object, tag_list: "lol, nope") + + tag = Tag.find_by(name: :includeme) + articles = described_class.cached_tagged_with_any(tag) + + expect(articles).to include(included) + expect(articles).not_to include(excluded) + expect(articles.to_a).to eq(described_class.tagged_with("includeme", any: true).to_a) + end + + it "can search among multiple tags" do + included = [ + create(object, tag_list: "omg, wtf"), + create(object, tag_list: "omg, lol"), + ] + excluded = create(object, tag_list: "nope, excluded") + + articles = described_class.cached_tagged_with_any("omg") + expected = described_class.tagged_with("omg", any: true).to_a + + expect(articles).to include(*included) + expect(articles).not_to include excluded + expect(articles.to_a).to include(*expected) + end + + it "can search for multiple tags" do + included = create(object, tag_list: "includeme, please, lol") + included_partial_match = create(object, tag_list: "includeme, omg") + excluded_no_match = create(object, tag_list: "excluded, omg") + + articles = described_class.cached_tagged_with_any(%w[includeme please]) + expected = described_class.tagged_with(%w[includeme please], any: true).to_a + + expect(articles).to include included + expect(articles).to include included_partial_match + expect(articles).not_to include excluded_no_match + + expect(articles.to_a).to include(*expected) + end + + it "can search for multiple tags when given an array of symbols" do + included = create(object, tag_list: "includeme, please, lol") + included_partial_match = create(object, tag_list: "includeme, omg") + excluded_no_match = create(object, tag_list: "excluded, omg") + + articles = described_class.cached_tagged_with_any(%i[includeme please]) + expected = described_class.tagged_with(%i[includeme please], any: true).to_a + + expect(articles).to include(included) + expect(articles).to include(included_partial_match) + expect(articles).not_to include(excluded_no_match) + + expect(articles.to_a).to include(*expected) + end + + it "can search for multiple tags when given an array of Tag objects" do + included = create(object, tag_list: "includeme, please, lol") + included_partial_match = create(object, tag_list: "includeme, omg") + excluded_no_match = create(object, tag_list: "excluded, omg") + + tags = Tag.where(name: %i[includeme please]).to_a + articles = described_class.cached_tagged_with_any(tags) + expected = described_class.tagged_with(%i[includeme please], any: true).to_a + + expect(articles).to include(included) + expect(articles).to include(included_partial_match) + expect(articles).not_to include(excluded_no_match) + + expect(articles.to_a).to include(*expected) + end + end + + describe ".not_cached_tagged_with_any" do + it "can exclude multiple tags when given an array of strings" do + included = create(object, tag_list: "includeme") + excluded1 = create(object, tag_list: "includeme, lol") + excluded2 = create(object, tag_list: "includeme, omg") + + articles = described_class + .cached_tagged_with_any("includeme") + .not_cached_tagged_with_any(%w[lol omg]) + + expect(articles).to include included + expect(articles).not_to include excluded1 + expect(articles).not_to include excluded2 + end + end +end