diff --git a/app/assets/fonts/Roboto-Bold.ttf b/app/assets/fonts/Roboto-Bold.ttf new file mode 100644 index 000000000..43da14d84 Binary files /dev/null and b/app/assets/fonts/Roboto-Bold.ttf differ diff --git a/app/assets/fonts/Roboto-Medium.ttf b/app/assets/fonts/Roboto-Medium.ttf new file mode 100644 index 000000000..ac0f908b9 Binary files /dev/null and b/app/assets/fonts/Roboto-Medium.ttf differ diff --git a/app/assets/images/rounded_mask.png b/app/assets/images/rounded_mask.png new file mode 100644 index 000000000..53a91fc6c Binary files /dev/null and b/app/assets/images/rounded_mask.png differ diff --git a/app/assets/images/social_template.png b/app/assets/images/social_template.png new file mode 100644 index 000000000..57b1d544b Binary files /dev/null and b/app/assets/images/social_template.png differ diff --git a/app/models/article.rb b/app/models/article.rb index 779bbd867..373b8a2a8 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -205,6 +205,7 @@ class Article < ApplicationRecord after_save :create_conditional_autovomits after_save :bust_cache after_save :collection_cleanup + after_save :generate_social_image after_update_commit :update_notifications, if: proc { |article| article.notifications.any? && !article.saved_changes.empty? @@ -984,6 +985,17 @@ class Article < ApplicationRecord touch_actor_latest_article_updated_at(destroying: destroying) end + def generate_social_image + return unless FeatureFlag.enabled?(:minimagick_social_images) + + return if main_image.present? + + change = saved_change_to_attribute?(:title) || saved_change_to_attribute?(:published_at) + return unless (change || social_image.blank?) && published + + Images::SocialImageWorker.perform_async(id, self.class.name) + end + def calculate_base_scores self.hotness_score = 1000 if hotness_score.blank? end diff --git a/app/models/organization.rb b/app/models/organization.rb index b81674a8b..f98b5cfaf 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -18,6 +18,7 @@ class Organization < ApplicationRecord before_save :generate_secret after_save :bust_cache + after_save :generate_social_images after_update_commit :conditionally_update_articles after_destroy_commit :bust_cache @@ -132,6 +133,15 @@ class Organization < ApplicationRecord private + def generate_social_images + return unless FeatureFlag.enabled?(:minimagick_social_images) + + change = saved_change_to_attribute?(:name) || saved_change_to_attribute?(:profile_image) + return unless change && articles.published.size.positive? + + Images::SocialImageWorker.perform_async(id, self.class.name) + end + def evaluate_markdown self.cta_processed_html = MarkdownProcessor::Parser.new(cta_body_markdown).evaluate_limited_markdown end diff --git a/app/models/user.rb b/app/models/user.rb index 1000700e0..fd0a34916 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -233,6 +233,7 @@ class User < ApplicationRecord after_create_commit :send_welcome_notification after_save :create_conditional_autovomits + after_save :generate_social_images after_commit :subscribe_to_mailchimp_newsletter after_commit :bust_cache @@ -582,6 +583,15 @@ class User < ApplicationRecord private + def generate_social_images + return unless FeatureFlag.enabled?(:minimagick_social_images) + + change = saved_change_to_attribute?(:name) || saved_change_to_attribute?(:profile_image) + return unless change && articles.published.size.positive? + + Images::SocialImageWorker.perform_async(id, self.class.name) + end + def create_users_settings_and_notification_settings_records self.setting = Users::Setting.create self.notification_setting = Users::NotificationSetting.create diff --git a/app/services/images/generate_social_image_magickally.rb b/app/services/images/generate_social_image_magickally.rb new file mode 100644 index 000000000..1c3ab021b --- /dev/null +++ b/app/services/images/generate_social_image_magickally.rb @@ -0,0 +1,196 @@ +module Images + MEDIUM_FONT_PATH = Rails.root.join("app/assets/fonts/Roboto-Medium.ttf").freeze + BOLD_FONT_PATH = Rails.root.join("app/assets/fonts/Roboto-Bold.ttf").freeze + TEMPLATE_PATH = Rails.root.join("app/assets/images/social_template.png").freeze + ROUNDED_MASK_PATH = Rails.root.join("app/assets/images/rounded_mask.png").freeze + + class GenerateSocialImageMagickally + def self.call(resource = nil) + new(resource).call + end + + def initialize(resource) + @resource = resource + @logo_url = Settings::General.logo_png + end + + def call + if @resource.is_a?(Article) + @user = @resource.user + read_files + url = generate_magickally(title: @resource.title, + date: @resource.readable_publish_date, + author_name: @user.name, + color: @user.setting.brand_color1) + @resource.update_column(:social_image, url) + ## We only need to bust article. All else can fade naturally + EdgeCache::BustArticle.call(@resource) + elsif @resource.is_a?(User) + @user = @resource + read_files + @resource.articles.published.where(organization_id: nil, main_image: nil).find_each do |article| + url = generate_magickally(title: article.title, + date: article.readable_publish_date, + author_name: @user.name, + color: @user.setting.brand_color1) + article.update_column(:social_image, url) + end + else # Organization + @user = @resource + read_files + @resource.articles.published.where(main_image: nil).find_each do |article| + url = generate_magickally(title: article.title, + date: article.readable_publish_date, + author_name: @user.name, + color: @user.bg_color_hex) + article.update_column(:social_image, url) + end + end + rescue StandardError => e + Rails.logger.error(e) + Honeybadger.notify(e) + end + + private + + def generate_magickally(title: nil, date: nil, author_name: nil, color: nil) + result = draw_stripe(color) + result = add_logo(result) + result = add_text(result, title, date, author_name) + result = add_profile_image(result) + upload_result(result) + end + + def draw_stripe(color) + color = "#111212" if color == "#000000" # pure black has minimagick side effects + @background_image.combine_options do |c| + c.fill color + c.draw "rectangle 0,0 1000,24" # adjust width according to your image width + end + end + + def add_logo(result) + if @logo_image + # Add white stroke to the overlay image + @logo_image.combine_options do |c| + c.stroke "white" + c.strokewidth "4" + c.fill "none" + c.draw "rectangle 0,0 1000,1000" # adjust as needed based on image size + end + + # Resize the overlay image + @logo_image.resize "64x64" + + result = @background_image.composite(@logo_image) do |c| + c.compose "Over" # OverCompositeOp + c.geometry "+850+372" # move the overlay to the top left + end + end + result + end + + def add_text(result, title, date, author_name) + title = wrap_text(title) + font_size = calculate_font_size(title) + + result.combine_options do |c| + c.gravity "West" # Set the origin for the text at the top left corner + c.pointsize font_size.to_s + c.draw "text 80,-39 '#{title}'" # Start drawing text 90 from the left and slightly north + c.fill "black" + c.font BOLD_FONT_PATH.to_s + end + + result.combine_options do |c| + c.gravity "Southwest" + c.pointsize "32" + c.draw "text 156,88 '#{author_name}'" # adjust coordinates as needed + c.fill "black" + c.font MEDIUM_FONT_PATH.to_s + end + + result.combine_options do |c| + c.gravity "Southwest" + c.pointsize "26" + c.draw "text 156,60 '#{date}'" # adjust coordinates as needed + c.fill "#525252" + end + end + + def add_profile_image(result) + profile_image_size = "64x64" + profile_image_location = "+80+63" + # Add subtext and author image + @author_image.resize profile_image_size + result = result.composite(@author_image) do |c| + c.compose "Over" + c.gravity "Southwest" + c.geometry profile_image_location + end + + @rounded_mask.resize profile_image_size + + result.composite(@rounded_mask) do |c| + c.compose "Over" + c.gravity "Southwest" + c.geometry profile_image_location + end + end + + def upload_result(result) + tempfile = Tempfile.new(["output", ".png"]) + result.write tempfile.path + image_uploader = ArticleImageUploader.new.tap do |uploader| + uploader.store!(tempfile) + end + # Don't forget to close and unlink (delete) the tempfile after you're done with it. + tempfile.close + tempfile.unlink + + # Return the uploaded url + image_uploader.url + end + + attr_reader :resource + + def calculate_font_size(text) + text_length = text.length + + if text_length < 18 + 88 + elsif text_length < 40 + 77 + elsif text_length < 55 + 65 + elsif text_length < 70 + 60 + else + 50 + end + end + + def wrap_text(text) + line_width = if text.length < 40 + 20 + elsif text.length < 70 + 27 + else + 35 + end + text.split("\n").map do |line| + line.length > line_width ? line.gsub(/(.{1,#{line_width}})(\s+|$)/, "\\1\n").strip : line + end * "\n" + end + + def read_files + # These are files we can open once for all the images we are generating within the loop. + @background_image = MiniMagick::Image.open(ActionController::Base.helpers.asset_path(TEMPLATE_PATH)) + @logo_image = MiniMagick::Image.open(@logo_url) if @logo_url.present? + image = @user.profile_image_90 + author_image_url = image.start_with?("http") ? image : Images::Profile::BACKUP_LINK + @author_image = MiniMagick::Image.open(author_image_url) + @rounded_mask = MiniMagick::Image.open(ActionController::Base.helpers.asset_path(ROUNDED_MASK_PATH)) + end + end +end diff --git a/app/view_objects/articles/social_image.rb b/app/view_objects/articles/social_image.rb index dcb63b3a3..942d0dc8a 100644 --- a/app/view_objects/articles/social_image.rb +++ b/app/view_objects/articles/social_image.rb @@ -41,8 +41,8 @@ module Articles end def user_defined_image - return article.social_image if article.social_image.present? return article.main_image if article.main_image.present? + return article.social_image if article.social_image.present? return article.video_thumbnail_url if article.video_thumbnail_url.present? end end diff --git a/app/workers/images/social_image_worker.rb b/app/workers/images/social_image_worker.rb new file mode 100644 index 000000000..009cd8c48 --- /dev/null +++ b/app/workers/images/social_image_worker.rb @@ -0,0 +1,12 @@ +module Images + class SocialImageWorker + include Sidekiq::Job + + sidekiq_options queue: :medium_priority, retry: 5 + + def perform(id, class_name) + object = class_name.constantize.find(id) + Images::GenerateSocialImageMagickally.call(object) + end + end +end diff --git a/lib/data_update_scripts/20230809210006_add_minimagick_social_images_feature_flag.rb b/lib/data_update_scripts/20230809210006_add_minimagick_social_images_feature_flag.rb new file mode 100644 index 000000000..52240e343 --- /dev/null +++ b/lib/data_update_scripts/20230809210006_add_minimagick_social_images_feature_flag.rb @@ -0,0 +1,7 @@ +module DataUpdateScripts + class AddMinimagickSocialImagesFeatureFlag + def run + FeatureFlag.add :minimagick_social_images + end + end +end diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index daa876659..d817571c8 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -1443,4 +1443,42 @@ RSpec.describe Article do expect(Languages::Detection).not_to have_received(:call) end end + + describe "#generate_social_image" do + before do + allow(Images::SocialImageWorker).to receive(:perform_async) + end + + context "when title or published_at attribute changes and published is true" do + it "triggers the Images::SocialImageWorker" do + article.body_markdown = "---\ntitle: New Title #{rand(1_000)}\npublished: true\n---\n\n# Hello World" + article.main_image = nil + article.save + expect(Images::SocialImageWorker).to have_received(:perform_async) + end + end + + context "when attributes have changed, but main image is present" do + it "does not trigger the Images::SocialImageWorker" do + article.body_markdown = "---\ntitle: New Title #{rand(1_000)}\ncover_image: https://example.com/i.jpg\npublished: true\n---\n\n# Hello World" + article.save + expect(Images::SocialImageWorker).not_to have_received(:perform_async) + end + end + + context "when neither title nor published_at attribute changes" do + it "does not trigger the Images::SocialImageWorker" do + article.save + expect(Images::SocialImageWorker).not_to have_received(:perform_async) + end + end + + context "when title or published_at attribute changes but published is false" do + it "does not trigger the Images::SocialImageWorker" do + article.body_markdown = "---\ntitle: New Title #{rand(1_000)}\npublished: false\n---\n\n# Hello World" + article.save + expect(Images::SocialImageWorker).not_to have_received(:perform_async) + end + end + end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index aa1e11032..466bb9ad5 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -391,6 +391,37 @@ RSpec.describe Organization do end end + describe "#generate_social_images" do + before do + allow(Images::SocialImageWorker).to receive(:perform_async) + create(:article, organization: organization) + end + + context "when the name or profile_image has changed and the organization has articles" do + it "calls SocialImageWorker.perform_async" do + organization.name = "New name for this org!" + organization.save + expect(Images::SocialImageWorker).to have_received(:perform_async) + end + end + + context "when the name or profile_image has not changed or the organization has no articles" do + it "does not call SocialImageWorker.perform_async" do + expect(Images::SocialImageWorker).not_to receive(:perform_async) + organization.save + end + end + + context "when the name or profile_image has changed and the organization has no articles" do + it "does not call SocialImageWorker.perform_async" do + organization.articles.destroy_all + organization.name = "New name for this org!!" + organization.save + expect(Images::SocialImageWorker).not_to receive(:perform_async) + end + end + end + describe "#bust_cache" do context "when a new user is added to the organization" do let(:user) { create(:user) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 16e23d8ab..9fd33fc4b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -926,4 +926,26 @@ RSpec.describe User do end end end + + describe "#generate_social_images" do + before do + create(:article, user: user) + allow(Images::SocialImageWorker).to receive(:perform_async) + end + + context "when the name or profile_image has changed and the user has articles" do + it "calls SocialImageWorker.perform_async" do + user.name = "New name for this user!" + user.save + expect(Images::SocialImageWorker).to have_received(:perform_async) + end + end + + context "when the name or profile_image has not changed or the user has no articles" do + it "does not call SocialImageWorker.perform_async" do + user.save + expect(Images::SocialImageWorker).not_to have_received(:perform_async) + end + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 527eb8dcb..5250a8b04 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -146,6 +146,7 @@ RSpec.configure do |config| # "Please stub a default value first if message might be received with other args as well." allow(FeatureFlag).to receive(:enabled?).and_call_original allow(FeatureFlag).to receive(:enabled?).with(:connect).and_return(true) + allow(FeatureFlag).to receive(:enabled?).with(:minimagick_social_images, any_args).and_return(true) end config.around(:each, :flaky) do |ex| @@ -217,6 +218,13 @@ RSpec.configure do |config| "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "User-Agent" => "Ruby" }).to_return(status: 200, body: "", headers: {}) + stub_request(:get, /assets\/icon/) + .with(headers: + { + "Accept" => "*/*", + "Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "User-Agent" => "Ruby" + }).to_return(status: 200, body: "", headers: {}) allow(Settings::Community).to receive(:community_description).and_return("Some description") allow(Settings::UserExperience).to receive(:public).and_return(true) diff --git a/spec/requests/stories_show_spec.rb b/spec/requests/stories_show_spec.rb index 98879b639..aca0aa91c 100644 --- a/spec/requests/stories_show_spec.rb +++ b/spec/requests/stories_show_spec.rb @@ -215,6 +215,18 @@ RSpec.describe "StoriesShow" do get article.path expect(response.body).not_to include("noindex") end + + it "renders og:image with main image if present ahead of social" do + article = create(:article, with_main_image: true, social_image: "https://example.com/image.jpg") + get article.path + expect(response.body).to include(%(property="og:image" content="#{article.main_image}")) + end + + it "renders og:image with social if present and main image not so much" do + article = create(:article, with_main_image: false, social_image: "https://example.com/image.jpg") + get article.path + expect(response.body).to include(%(property="og:image" content="#{article.social_image}")) + end end describe "GET /:username (org)" do diff --git a/spec/services/images/generate_social_image_magickally_spec.rb b/spec/services/images/generate_social_image_magickally_spec.rb new file mode 100644 index 000000000..f6174eccd --- /dev/null +++ b/spec/services/images/generate_social_image_magickally_spec.rb @@ -0,0 +1,289 @@ +require "rails_helper" + +RSpec.describe Images::GenerateSocialImageMagickally, type: :model do + let(:user) { create(:user) } + let!(:article) { create(:article, user_id: user.id, with_main_image: false) } + let(:organization) { create(:organization) } + let!(:second_article) { create(:article, user_id: user.id, organization_id: organization.id, with_main_image: false) } + let(:background_image) { instance_double(MiniMagick::Image) } + + # rubocop:disable Style/Send + # rubocop:disable Layout/LineLength + # rubocop:disable RSpec/VerifiedDoubles + # rubocop:disable RSpec/ContextWording + # rubocop:disable Style/Semicolon + # rubocop:disable RSpec/NestedGroups + describe ".call" do + context "when resource is an Article" do + let(:generator) { described_class.new(article) } + + before do + allow(described_class).to receive(:new).and_return(generator) + allow(generator).to receive(:read_files) + allow(generator).to receive(:add_logo) + allow(generator).to receive(:add_text) + allow(generator).to receive(:add_profile_image) + allow(generator).to receive(:upload_result).and_return("image_url") + allow(generator).to receive(:generate_magickally) + end + + it "calls the class methods" do + described_class.call(article) + expect(generator).to have_received(:generate_magickally).once + end + + it "updates article to have social image" do + allow(generator).to receive(:generate_magickally).and_return("https://www.example.com") + described_class.call(article) + expect(article.reload.social_image).to eq("https://www.example.com") + end + + it "converts black color to off-black" do + allow(background_image).to receive(:combine_options).with(any_args) do |&block| + command = MiniMagick::CommandBuilder.new(:mogrify) + block.call(command) + expect(command.args.join(" ")).to include("fill '#111212'") + end + + described_class.call(article) + end + + it "busts article cache" do + allow(EdgeCache::BustArticle).to receive(:call).with(article) + described_class.call(article) + end + end + + context "when resource is a User" do + let(:generator) { described_class.new(user) } + + before do + allow(described_class).to receive(:new).and_return(generator) + allow(generator).to receive(:read_files) + allow(generator).to receive(:generate_magickally) + allow(user).to receive(:profile_image_90).and_return("https://www.example.com/image.png") + end + + it "calls the class methods for each published article" do + described_class.call(user) + expect(generator).to have_received(:generate_magickally).once + .with( + title: article.title, + date: article.readable_publish_date, + author_name: user.name, + color: user.setting.brand_color1, + ) + end + + it "updates article to have social image" do + allow(generator).to receive(:generate_magickally).and_return("https://www.example.com") + described_class.call(user) + expect(article.reload.social_image).to eq("https://www.example.com") + end + end + + context "when resource is an Organization" do + let(:generator) { described_class.new(organization) } + + before do + allow(described_class).to receive(:new).and_return(generator) + allow(generator).to receive(:read_files) + end + + it "calls the class methods for each published article" do + allow(generator).to receive(:generate_magickally) + described_class.call(organization) + expect(generator).to have_received(:generate_magickally).once + end + + it "updates article to have social image" do + allow(generator).to receive(:generate_magickally).and_return("https://www.example.com") + described_class.call(organization) + expect(generator).to have_received(:generate_magickally).once + expect(second_article.reload.social_image).to eq("https://www.example.com") + end + end + + context "when calculate_font_size" do + let(:generator) { described_class.new(article) } + + it "returns the correct font size for short text" do + expect(generator.send(:calculate_font_size, "short text")).to eq(88) + end + + it "returns the correct font size for medium text" do + medium_text = "This is a slightly longer text." + expect(generator.send(:calculate_font_size, medium_text)).to eq(77) + end + + it "returns the correct font size for medium-to-long text" do + medium_long_text = "This is a slightly longer text. Slightly longer." + expect(generator.send(:calculate_font_size, medium_long_text)).to eq(65) + end + + it "returns the correct font size for medium-to-long text" do + almost_long_text = "This is a slightly longer text. Slightly longer than the last." + expect(generator.send(:calculate_font_size, almost_long_text)).to eq(60) + end + + it "returns the correct font size for long text" do + long_text = "This is a very long text that is definitely more than 70 characters long + and should return a smaller font size" + expect(generator.send(:calculate_font_size, long_text)).to eq(50) + end + end + + context "wrap_text" do + let(:generator) { described_class.new(article) } + + it "returns the same text when the text length is less than 40" do + text = "This is a short text" + expect(generator.send(:wrap_text, text)).to eq(text) + end + + it "returns wrapped text when the text length is between 40 and 70" do + text = "This is a slightly longer text that is about 50 characters long and should be wrapped" + wrapped_text = "This is a slightly longer text that\nis about 50 characters long and\nshould be wrapped" + expect(generator.send(:wrap_text, text)).to eq(wrapped_text) + end + + it "returns wrapped text when the text length is more than 70" do + text = "This is a very long text that is definitely more than 70 characters long and should be wrapped over multiple lines" + wrapped_text = "This is a very long text that is\ndefinitely more than 70 characters\nlong and should be wrapped over\nmultiple lines" + expect(generator.send(:wrap_text, text)).to eq(wrapped_text) + end + end + + context "add_text" do + let(:generator) { described_class.new(article) } + let(:result_image) { double(MiniMagick::Tool::Convert) } + + before do + allow(generator).to receive(:calculate_font_size).and_return(40) + allow(generator).to receive(:wrap_text).and_return("Wrapped Text") + allow(result_image).to receive(:combine_options) { |&block| block.call(result_image); result_image } + allow(result_image).to receive(:gravity) + allow(result_image).to receive(:pointsize) + allow(result_image).to receive(:draw) + allow(result_image).to receive(:fill) + allow(result_image).to receive(:font) + generator.instance_variable_set(:@background_image, result_image) + end + + it "adds title, date, and author_name text to the image" do + generator.send(:add_text, result_image, "title", "date", "author_name") + expect(result_image).to have_received(:combine_options).exactly(3).times + end + end + + context "add_profile_image" do + let(:user) { create(:user) } + let(:article) { create(:article, user_id: user.id, with_main_image: false) } + let(:generator) { described_class.new(article) } + let(:result_image) { double(MiniMagick::Tool::Convert) } + let(:author_image) { double(MiniMagick::Tool::Convert) } + let(:rounded_mask) { double(MiniMagick::Tool::Convert) } + + before do + allow(result_image).to receive(:composite).and_return(result_image) + allow(author_image).to receive(:resize) + allow(rounded_mask).to receive(:resize) + + generator.instance_variable_set(:@background_image, result_image) + generator.instance_variable_set(:@author_image, author_image) + generator.instance_variable_set(:@rounded_mask, rounded_mask) + end + + it "adds the profile image and rounded mask to the image" do + generator.send(:add_profile_image, result_image) + expect(author_image).to have_received(:resize).with("64x64") + expect(rounded_mask).to have_received(:resize).with("64x64") + expect(result_image).to have_received(:composite).twice + end + end + + context "add_logo" do + let(:user) { create(:user) } + let(:article) { create(:article, user_id: user.id, with_main_image: false) } + let(:generator) { described_class.new(article) } + let(:result_image) { double(MiniMagick::Tool::Convert) } + let(:logo_image) { double(MiniMagick::Tool::Convert) } + + before do + allow(result_image).to receive(:composite).and_return(result_image) + allow(logo_image).to receive(:combine_options).and_yield(logo_image) + allow(logo_image).to receive(:resize) + allow(logo_image).to receive(:stroke) + allow(logo_image).to receive(:strokewidth) + allow(logo_image).to receive(:fill) + allow(logo_image).to receive(:draw) + + generator.instance_variable_set(:@background_image, result_image) + generator.instance_variable_set(:@logo_image, logo_image) + end + + context "when logo image is present" do + it "adds the logo to the image" do + generator.send(:add_logo, result_image) + + expect(logo_image).to have_received(:combine_options) + expect(logo_image).to have_received(:stroke).with("white") + expect(logo_image).to have_received(:strokewidth).with("4") + expect(logo_image).to have_received(:fill).with("none") + expect(logo_image).to have_received(:draw).with("rectangle 0,0 1000,1000") + expect(logo_image).to have_received(:resize).with("64x64") + expect(result_image).to have_received(:composite).with(logo_image) + end + end + + context "when logo image is not present" do + before do + generator.instance_variable_set(:@logo_image, nil) + end + + it "does not attempt to add the logo to the image" do + generator.send(:add_logo, result_image) + expect(result_image).not_to have_received(:composite) + end + end + + context "upload_result" do + let(:generator) { described_class.new(article) } + let(:result_image) { double(MiniMagick::Tool::Convert) } + let(:tempfile) { instance_double(Tempfile, path: "/tmp/output.png") } + let(:uploader) { instance_double(ArticleImageUploader) } + + before do + allow(Tempfile).to receive(:new).and_return(tempfile) + allow(tempfile).to receive(:close) + allow(tempfile).to receive(:unlink) + allow(result_image).to receive(:write) + + allow(ArticleImageUploader).to receive(:new).and_return(uploader) + allow(uploader).to receive(:store!) + allow(uploader).to receive(:url).and_return("http://example.com/social_image.png") + + generator.instance_variable_set(:@background_image, result_image) + end + + it "creates a tempfile, writes the image, uploads the image, then cleans up the tempfile" do + expect(generator.send(:upload_result, result_image)).to eq "http://example.com/social_image.png" + + expect(Tempfile).to have_received(:new).with(["output", ".png"]) + expect(result_image).to have_received(:write).with(tempfile.path) + expect(ArticleImageUploader).to have_received(:new) + expect(uploader).to have_received(:store!).with(tempfile) + expect(tempfile).to have_received(:close) + expect(tempfile).to have_received(:unlink) + expect(uploader).to have_received(:url) + end + end + end + end + # rubocop:enable Style/Send + # rubocop:enable RSpec/VerifiedDoubles + # rubocop:enable Layout/LineLength + # rubocop:enable RSpec/ContextWording + # rubocop:enable Style/Semicolon + # rubocop:enable RSpec/NestedGroups +end