Scheduling articles (#17939)

* Article query spec for scheduled articles

* Added scheduled article badge on the user dashboard

* Added published_at field to editor options

* Accept and validate published_at from editor

* Refactor published_at validation

* Allow 1-minute difference in published_at

* Notice on an unpublished article page

* Added specs for 'Click to edit' link on scheduled article preview page

* ContextNotification model

* Articles::Publish worker

* Added specs for articles publish worker

* Schedule publish articles worker

* Added tests to check for scheduled posts in feeds

* Don't allow managing scheduled articles

* Don't send notifications for scheduled articles

* Set published_at in Articles::Updater when publishing

* Published_at value in post options

* Pass timezone and set published_at accordingly

* Limit setting published_at to the future

* Readonly published_at for articles that were already published

* Chagning published_at format in editor v1 (start)

* Changed published_at format in frontmatter, specs

* Added specs for updating published_at from frontmatter

* Fixed accepting past published_at for articles published_from_feed

* Enabled published_at validation: don't allow updating published_at for already published articles

* Validate published_at on create

* Added a spec for updating published_at for exported articles

* Fixed specs related to creating articles with past published_at

* Fixed specs related to past published_at for articles

* Added a hack so that admins would be able to update published_at

* Switch button text schedule/publish when changin publishedAt

* Fixed saving published_at with timezone

* Added a feature flag for scheduling articles

* Default text in markdown editor depends on feature flag

* Enable article editor cache again

* Fixed the default value in the markdown editor

* Fix sitemaps spec

* Removed tooltip

* Fixed articles update specs

* Added missing locales

* Fixed article create specs

* Fixed spec

* Removed commented code

* Returned enabling extensions in the schema

* Returned accidentally deleted constraint

* Make articles query spec more stable

Co-authored-by: Jeremy Friesen <jeremy.n.friesen@gmail.com>

* Removed commented code

* Removed unused code

* A clearer policy

Co-authored-by: Jeremy Friesen <jeremy.n.friesen@gmail.com>

* Use StringInquirer for article current state

* Added a note and todo to articles factory past trait

* Remove duplicated PropType

Co-authored-by: Suzanne Aitchison <suzanne@forem.com>

* Refactor query in the Articles::PublishWorker

Co-authored-by: Mac Siri <krairit.siri@gmail.com>

* Refactor articleForm.jsx

Co-authored-by: Mac Siri <krairit.siri@gmail.com>

* Removed specs that are no longer relevant

* Removed useless onKeyUp on a hidden input

* Refactored articleForm

* Hide scheduling from post options when published_at is readonly

* Run sends notifications worker every 5 minutes instead of every minute

Co-authored-by: Jeremy Friesen <jeremy.n.friesen@gmail.com>
Co-authored-by: Suzanne Aitchison <suzanne@forem.com>
Co-authored-by: Mac Siri <krairit.siri@gmail.com>
This commit is contained in:
Anna Buianova 2022-07-07 17:32:49 +03:00 committed by GitHub
parent 5e916b74c1
commit ff76cdd3c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 797 additions and 211 deletions

View file

@ -40,7 +40,7 @@ module Admin
def update
article = Article.find(params[:id])
if article.update(article_params)
if article.update(article_params.merge(admin_update: true))
flash[:success] = I18n.t("admin.articles_controller.saved")
else
flash[:danger] = article.errors_as_sentence

View file

@ -146,7 +146,6 @@ class ArticlesController < ApplicationController
def create
authorize Article
@user = current_user
article = Articles::Creator.call(@user, article_params_json)
@ -160,7 +159,6 @@ class ArticlesController < ApplicationController
def update
authorize @article
@user = @article.user || current_user
updated = Articles::Updater.call(@user, @article, article_params_json)
respond_to do |format|
@ -303,6 +301,8 @@ class ArticlesController < ApplicationController
# TODO: refactor all of this update logic into the Articles::Updater possibly,
# ideally there should only be one place to handle the update logic
def article_params_json
return @article_params_json if @article_params_json
params.require(:article) # to trigger the correct exception in case `:article` is missing
params["article"].transform_keys!(&:underscore)
@ -312,7 +312,7 @@ class ArticlesController < ApplicationController
else
%i[
title body_markdown main_image published description video_thumbnail_url
tag_list canonical_url series collection_id archived
tag_list canonical_url series collection_id archived published_at timezone
]
end
@ -325,7 +325,14 @@ class ArticlesController < ApplicationController
allowed_params << :organization_id
end
params.require(:article).permit(allowed_params)
time_zone_str = params["article"].delete("timezone")
if params["article"]["published_at"]
time_zone = Time.find_zone(time_zone_str)
time_zone ||= Time.find_zone("UTC")
params["article"]["published_at"] = time_zone.parse(params["article"]["published_at"])
end
@article_params_json = params.require(:article).permit(allowed_params)
end
def allowed_to_change_org_id?

View file

@ -274,7 +274,7 @@ class StoriesController < ApplicationController
end
def permission_denied?
!@article.published && params[:preview] != @article.password
(!@article.published || @article.scheduled?) && params[:preview] != @article.password
end
def assign_co_authors

View file

@ -10,8 +10,19 @@ class ArticleDecorator < ApplicationDecorator
DataInfo.to_json(object: cached_user, class_name: "User", id: user_id, style: "full")
end
def current_state
state = if !published?
"unpublished"
elsif scheduled?
"scheduled"
else
"published"
end
ActiveSupport::StringInquirer.new(state)
end
def current_state_path
published ? "/#{username}/#{slug}" : "/#{username}/#{slug}?preview=#{password}"
current_state.published? ? "/#{username}/#{slug}" : "/#{username}/#{slug}?preview=#{password}"
end
def processed_canonical_url

View file

@ -18,7 +18,7 @@ import { getOSKeyboardModifierKeyString } from '@utilities/runtime';
/* global activateRunkitTags */
/*
Although the state fields: id, description, canonicalUrl, series, allSeries and
Although the state fields: id, description, canonicalUrl, publishedAt, series, allSeries and
editing are not used in this file, they are important to the
editor.
*/
@ -63,6 +63,7 @@ export class ArticleForm extends Component {
article: PropTypes.string.isRequired,
organizations: PropTypes.string,
siteLogo: PropTypes.string.isRequired,
schedulingEnabled: PropTypes.bool.isRequired,
};
static defaultProps = {
@ -71,7 +72,7 @@ export class ArticleForm extends Component {
constructor(props) {
super(props);
const { article, version, siteLogo } = this.props;
const { article, version, siteLogo, schedulingEnabled } = this.props;
let { organizations } = this.props;
this.article = JSON.parse(article);
organizations = organizations ? JSON.parse(organizations) : null;
@ -102,10 +103,13 @@ export class ArticleForm extends Component {
tagList: this.article.cached_tag_list || '',
description: '', // eslint-disable-line react/no-unused-state
canonicalUrl: this.article.canonical_url || '', // eslint-disable-line react/no-unused-state
publishedAt: this.article.published_at || '', // eslint-disable-line react/no-unused-state
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '', // eslint-disable-line react/no-unused-state
series: this.article.series || '', // eslint-disable-line react/no-unused-state
allSeries: this.article.all_series || [], // eslint-disable-line react/no-unused-state
bodyMarkdown: this.article.body_markdown || '',
published: this.article.published || false,
schedulingEnabled,
previewShowing: false,
previewLoading: false,
previewResponse: { processed_html: '' },
@ -331,6 +335,7 @@ export class ArticleForm extends Component {
tagList: this.article.cached_tag_list || '',
description: '', // eslint-disable-line react/no-unused-state
canonicalUrl: this.article.canonical_url || '', // eslint-disable-line react/no-unused-state
publishedAt: this.article.published_at || '', // eslint-disable-line react/no-unused-state
series: this.article.series || '', // eslint-disable-line react/no-unused-state
allSeries: this.article.all_series || [], // eslint-disable-line react/no-unused-state
bodyMarkdown: this.article.body_markdown || '',
@ -392,9 +397,11 @@ export class ArticleForm extends Component {
tagList,
bodyMarkdown,
published,
publishedAt,
previewShowing,
previewLoading,
previewResponse,
schedulingEnabled,
submitting,
organizations,
organizationId,
@ -491,6 +498,8 @@ export class ArticleForm extends Component {
<EditorActions
published={published}
publishedAt={publishedAt}
schedulingEnabled={schedulingEnabled}
version={version}
onPublish={this.onPublish}
onSaveDraft={this.onSaveDraft}

View file

@ -8,6 +8,8 @@ export const EditorActions = ({
onPublish,
onClearChanges,
published,
publishedAt,
schedulingEnabled,
edited,
version,
passedData,
@ -35,6 +37,16 @@ export const EditorActions = ({
);
}
const now = new Date();
const publishedAtDate = publishedAt ? new Date(publishedAt) : now;
const schedule = publishedAtDate > now;
const saveButtonText = schedule
? 'Schedule'
: published || isVersion1
? 'Save changes'
: 'Publish';
return (
<div className="crayons-article-form__footer">
<Button
@ -43,7 +55,7 @@ export const EditorActions = ({
onClick={onPublish}
disabled={previewLoading}
>
{published || isVersion1 ? 'Save changes' : 'Publish'}
{saveButtonText}
</Button>
{!(published || isVersion1) && (
@ -59,6 +71,7 @@ export const EditorActions = ({
{isVersion2 && (
<Options
passedData={passedData}
schedulingEnabled={schedulingEnabled}
onConfigChange={onConfigChange}
onSaveDraft={onSaveDraft}
previewLoading={previewLoading}
@ -82,6 +95,8 @@ EditorActions.propTypes = {
onSaveDraft: PropTypes.func.isRequired,
onPublish: PropTypes.func.isRequired,
published: PropTypes.bool.isRequired,
publishedAt: PropTypes.string.isRequired,
schedulingEnabled: PropTypes.bool.isRequired,
edited: PropTypes.bool.isRequired,
version: PropTypes.string.isRequired,
onClearChanges: PropTypes.func.isRequired,

View file

@ -11,19 +11,45 @@ import CogIcon from '@images/cog.svg';
* @param {Function} props.onSaveDraft Callback for when the post draft is saved
* @param {Function} props.onConfigChange Callback for when the config options have changed
*/
function toISOStringLocal(datetime) {
const month = (datetime.getMonth() + 1).toString().padStart(2, '0');
const day = datetime.getDate().toString().padStart(2, '0');
return [
datetime.getFullYear(),
'-',
month,
'-',
day,
'T',
datetime.toLocaleTimeString(),
].join('');
}
export const Options = ({
passedData: {
published = false,
publishedAt = '',
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
allSeries = [],
canonicalUrl = '',
series = '',
},
schedulingEnabled,
onSaveDraft,
onConfigChange,
previewLoading,
}) => {
let publishedField = '';
let existingSeries = '';
let publishedAtField = '';
const publishedDate = new Date(publishedAt);
const currentDate = new Date();
const localPublishedAt = toISOStringLocal(publishedDate);
const minPublishedAt = toISOStringLocal(currentDate);
const readonlyPublishedAt = published && publishedDate < currentDate;
if (allSeries.length > 0) {
const seriesNames = allSeries.map((name, index) => {
@ -66,6 +92,34 @@ export const Options = ({
</div>
);
}
if (schedulingEnabled && !readonlyPublishedAt) {
publishedAtField = (
<div className="crayons-field mb-6">
<label htmlFor="publishedAt" className="crayons-field__label">
Schedule Publication
</label>
<input
type="datetime-local"
min={minPublishedAt}
value={localPublishedAt} // "2022-04-28T15:00:00"
className="crayons-textfield"
name="publishedAt"
onChange={onConfigChange}
id="publishedAt"
placeholder="..."
/>
<input
type="hidden"
value={timezone} // "Asia/Magadan"
className="crayons-textfield"
name="timezone"
id="timezone"
placeholder="..."
/>
</div>
);
}
return (
<div className="s:relative">
<Button
@ -104,6 +158,7 @@ export const Options = ({
id="canonicalUrl"
/>
</div>
{publishedAtField}
<div className="crayons-field mb-6">
<label htmlFor="series" className="crayons-field__label">
Series
@ -140,10 +195,13 @@ export const Options = ({
Options.propTypes = {
passedData: PropTypes.shape({
published: PropTypes.bool.isRequired,
publishedAt: PropTypes.string.isRequired,
timezone: PropTypes.string.isRequired,
allSeries: PropTypes.array.isRequired,
canonicalUrl: PropTypes.string.isRequired,
series: PropTypes.string.isRequired,
}).isRequired,
schedulingEnabled: PropTypes.bool.isRequired,
onSaveDraft: PropTypes.func.isRequired,
onConfigChange: PropTypes.func.isRequired,
previewLoading: PropTypes.bool.isRequired,

View file

@ -24,14 +24,15 @@ function loadForm() {
window.csrfToken = csrfToken;
const root = document.querySelector('main');
const { article, organizations, version, siteLogo } = root.dataset;
const { article, organizations, version, siteLogo, schedulingEnabled } =
root.dataset;
render(
<ArticleForm
article={article}
organizations={organizations}
version={version}
siteLogo={siteLogo}
schedulingEnabled={schedulingEnabled == 'true'}
/>,
root,
root.firstElementChild,

View file

@ -25,7 +25,10 @@ class Article < ApplicationRecord
# @see Articles::CachedEntity caching strategy for entity attributes
ATTRIBUTES_CACHED_FOR_RELATED_ENTITY = %i[name profile_image profile_image_url slug username].freeze
attr_accessor :publish_under_org
# admin_update was added as a hack to bypass published_at validation when admin is updating
# TODO: [@lightalloy] remove published_at validation from the model and
# move it to the services where the create/update takes place to avoid using hacks
attr_accessor :publish_under_org, :admin_update
attr_writer :series
delegate :name, to: :user, prefix: true
@ -99,6 +102,8 @@ class Article < ApplicationRecord
has_many :mentions, as: :mentionable, inverse_of: :mentionable, dependent: :delete_all
has_many :comments, as: :commentable, inverse_of: :commentable, dependent: :nullify
has_many :context_notifications, as: :context, inverse_of: :context, dependent: :delete_all
has_many :context_notifications_published, -> { where(context_notifications: { action: "Published" }) },
as: :context, inverse_of: :context, class_name: "ContextNotification"
has_many :html_variant_successes, dependent: :nullify
has_many :html_variant_trials, dependent: :nullify
has_many :notification_subscriptions, as: :notifiable, inverse_of: :notifiable, dependent: :delete_all
@ -153,9 +158,10 @@ class Article < ApplicationRecord
validates :video_source_url, url: { allow_blank: true, schemes: ["https"] }
validates :video_state, inclusion: { in: %w[PROGRESSING COMPLETED] }, allow_nil: true
validates :video_thumbnail_url, url: { allow_blank: true, schemes: %w[https http] }
validate :future_or_current_published_at, on: :create
validate :has_correct_published_at?, on: :update, unless: :admin_update
validate :canonical_url_must_not_have_spaces
validate :past_or_present_date
validate :validate_collection_permission
validate :validate_tag
validate :validate_video
@ -164,7 +170,7 @@ class Article < ApplicationRecord
validate :validate_co_authors_must_not_be_the_same, unless: -> { co_author_ids.blank? }
validate :validate_co_authors_exist, unless: -> { co_author_ids.blank? }
before_validation :evaluate_markdown, :create_slug
before_validation :evaluate_markdown, :create_slug, :set_published_date
before_validation :remove_prohibited_unicode_characters
before_validation :normalize_title
before_save :set_cached_entities
@ -174,7 +180,6 @@ class Article < ApplicationRecord
before_save :fetch_video_duration
before_save :set_caches
before_create :create_password
after_create :notify_slack_channel_about_publication
after_update :notify_slack_channel_about_publication, if: -> { published && saved_change_to_published? }
before_destroy :before_destroy_actions, prepend: true
@ -440,6 +445,10 @@ class Article < ApplicationRecord
end
end
def scheduled?
published_at.future?
end
def search_id
"article_#{id}"
end
@ -476,7 +485,7 @@ class Article < ApplicationRecord
end
def current_state_path
published ? "/#{username}/#{slug}" : "/#{username}/#{slug}?preview=#{password}"
published && !scheduled? ? "/#{username}/#{slug}" : "/#{username}/#{slug}?preview=#{password}"
end
def has_frontmatter?
@ -714,7 +723,10 @@ class Article < ApplicationRecord
self.title = front_matter["title"] if front_matter["title"].present?
set_tag_list(front_matter["tags"]) if front_matter["tags"].present?
self.published = front_matter["published"] if %w[true false].include?(front_matter["published"].to_s)
self.published_at = parse_date(front_matter["date"]) if published
self.published_at = front_matter["published_at"] if front_matter["published_at"]
self.published_at ||= parse_date(front_matter["date"]) if published
set_main_image(front_matter)
self.canonical_url = front_matter["canonical_url"] if front_matter["canonical_url"].present?
@ -806,10 +818,21 @@ class Article < ApplicationRecord
errors.add(:co_author_ids, I18n.t("models.article.invalid_coauthor"))
end
def past_or_present_date
return unless published_at && published_at > Time.current
def future_or_current_published_at
# allow published_at in the future or within 15 minutes in the past
return if !published || published_at > Time.current - (15 * 60 * 60)
errors.add(:date_time, I18n.t("models.article.invalid_date"))
errors.add(:published_at, I18n.t("models.article.future_or_current_published_at"))
end
def has_correct_published_at?
return unless published_at_was
# don't allow editing published_at if an article has already been published
# allow changes within one minute in case of editing via frontmatter w/o specifying seconds
return unless published_was && published_at_was < Time.current &&
changes["published_at"] && !(published_at_was - published_at).between?(-60, 60)
errors.add(:published_at, I18n.t("models.article.immutable_published_at"))
end
def canonical_url_must_not_have_spaces
@ -854,7 +877,6 @@ class Article < ApplicationRecord
end
def set_all_dates
set_published_date
set_featured_number
set_crossposted_at
set_last_comment_at

View file

@ -174,10 +174,6 @@ class ApplicationPolicy
update?
end
def manage?
update? && record.published
end
def destroy?
false
end

View file

@ -135,6 +135,10 @@ class ArticlePolicy < ApplicationPolicy
user_author? || user_super_admin? || user_org_admin? || user_any_admin?
end
def manage?
update? && record.published? && !record.scheduled?
end
def stats?
require_user!
user_author? || user_super_admin? || user_org_admin?

View file

@ -2,7 +2,7 @@ module Articles
class Attributes
ATTRIBUTES = %i[archived body_markdown canonical_url description
edited_at main_image organization_id user_id published
title video_thumbnail_url].freeze
title video_thumbnail_url published_at].freeze
attr_reader :attributes, :article_user
@ -11,12 +11,17 @@ module Articles
@article_user = article_user
end
def for_update(update_edited_at: false)
def for_update(update_edited_at: false, update_published_at: false)
hash = attributes.slice(*ATTRIBUTES)
# don't reset the collection when no series was passed
hash[:collection] = collection if attributes.key?(:series)
hash[:tag_list] = tag_list
hash[:edited_at] = Time.current if update_edited_at
if update_published_at
hash[:published_at] = Time.current
elsif hash[:published_at]
hash[:published_at] = hash[:published_at].to_datetime
end
hash
end

View file

@ -82,9 +82,11 @@ module Articles
end
def user_editor_v1
published_at = Time.current.strftime("%Y-%m-%d %H:%M %z")
published_at_str = FeatureFlag.enabled?(:schedule_articles) ? "# published_at: #{published_at}\n" : ""
body = "---\ntitle: \npublished: false\ndescription: " \
"\ntags: \n# cover_image: https://direct_url_to_image.jpg" \
"\n# Use a ratio of 100:42 for best results.\n---\n\n"
"\n# Use a ratio of 100:42 for best results.\n#{published_at_str}---\n\n"
Article.new(
body_markdown: body,

View file

@ -18,9 +18,6 @@ module Articles
# Subscribe author to notifications for all comments on their article.
NotificationSubscription.create(user: user, notifiable_id: article.id, notifiable_type: "Article",
config: "all_comments")
# Send notifications to any mentioned users, followed by any users who follow the article's author.
Notification.send_to_mentioned_users_and_followers(article) if article.published?
end
article

View file

@ -17,18 +17,21 @@ module Articles
# updated edited time only if already published and not edited by an admin
update_edited_at = article.user == user && article.published
attrs = Articles::Attributes.new(article_params, article.user).for_update(update_edited_at: update_edited_at)
# TODO: move setting published_at to Articles::Attributes
# when publishing (draft => published), and published_at is nil or is in the past, set it to Time.current
# except for exported articles
publishing = !article.published && article_params[:published]
past_published_at = !article_params[:published_at] || article_params[:published_at] < Time.current
update_published_at = publishing && !article.published_from_feed && past_published_at
attrs = Articles::Attributes.new(article_params, article.user)
.for_update(update_edited_at: update_edited_at,
update_published_at: update_published_at)
success = article.update(attrs)
if success
user.rate_limiter.track_limit_by_action(:article_update)
if article.published && article.saved_change_to_published_at.present?
# The first time that an article is published, we want to send notifications to any mentioned users first,
# and then send notifications to any users who follow the article's author so as to avoid double mentions.
Notification.send_to_mentioned_users_and_followers(article)
elsif article.published
if article.published && article.saved_change_to_published.blank?
# If the article has already been published and is only being updated, then we need to create
# mentions and send notifications to mentioned users inline via the Mentions::CreateAll service.
Mentions::CreateAll.call(article)

View file

@ -37,10 +37,11 @@
</style>
<main id="main-content"
data-article="<%= article.to_json(only: %i[id title cached_tag_list published body_markdown main_image organization_id canonical_url updated_at], methods: %i[series all_series]) %>"
data-article="<%= article.to_json(only: %i[id title cached_tag_list published published_at body_markdown main_image organization_id canonical_url updated_at], methods: %i[series all_series]) %>"
data-organizations="<%= organizations&.to_json(only: %i[id name bg_color_hex text_color_hex], methods: [:profile_image_90]) %>"
data-version="<%= version %>"
data-site-logo='<%= render "layouts/logo" %>'>
data-site-logo='<%= render "layouts/logo" %>'
data-scheduling-enabled="<%= FeatureFlag.enabled?(:schedule_articles) %>">
<form class="crayons-article-form crayons-article-form--<%= version %>">
<div class="crayons-article-form__header">

View file

@ -1,6 +1,6 @@
<% title t("views.editor.heading.new") %>
<%= content_for :page_meta do %>
<link rel="canonical" href="<%= app_url("/new") %>" />
<link rel="canonical" href="<%= app_url("/new") %>" />
<% end %>
<% if user_signed_in? %>

View file

@ -65,11 +65,11 @@
<main id="main-content" class="crayons-layout__content grid gap-4">
<div class="article-wrapper">
<% if !@article.published %>
<% unless @article.current_state.published? %>
<div class="crayons-notice crayons-notice--danger mb-4" aria-live="polite">
<strong><%= t("views.articles.unpublished.subtitle") %></strong><%= t("views.articles.unpublished.text") %>
<strong><%= t("views.articles.#{@article.current_state}.subtitle") %></strong><%= t("views.articles.#{@article.current_state}.text") %>
<% if user_signed_in? %>
<a id="author-click-to-edit" href="<%= @article.path %>/edit" class="fw-bold" style="display: none;"><%= t("views.articles.unpublished.edit") %></a>
<a id="author-click-to-edit" href="<%= @article.path %>/edit" class="fw-bold" style="display: none;"><%= t("views.articles.#{@article.current_state}.edit") %></a>
<% end %>
</div>
<% end %>

View file

@ -15,7 +15,7 @@
</h3>
<% if article.published %>
<p class="fs-s color-base-60 js-dashboard-story-details">
<strong class="fw-medium"><%= t("views.dashboard.article.published") %></strong>
<strong class="fw-medium"><%= t("views.dashboard.article.#{article.scheduled? ? 'scheduled_at' : 'published'}") %></strong>
<%= local_date(article.published_timestamp, show_year: article.displayable_published_at.year != Time.current.year) %>
<% if article.edited? %>
<strong class="fw-medium pl-2"><%= t("views.dashboard.article.edited") %></strong>
@ -31,6 +31,8 @@
<div class="fs-s color-base-60">
<% if !article.published? %>
<a href="<%= article.path %>/edit" class="c-indicator c-indicator--warning"><%= t("views.dashboard.article.draft") %></a>
<% elsif article.scheduled? %>
<a href="<%= article.path %>/edit" class="c-indicator c-indicator--warning"><%= t("views.dashboard.article.scheduled") %></a>
<% else %>
<div class="flex flex-nowrap whitespace-nowrap" data-analytics-pageviews data-article-id="<%= article.id %>">
<span class="flex items-center p-1" title="<%= t("views.dashboard.article.reactions.title") %>">
@ -86,7 +88,7 @@
<button class="crayons-btn crayons-btn--ghost crayons-btn--s" title="<%= t("views.dashboard.article.pin.title") %>"><%= t("views.dashboard.article.pin.text") %></button>
<% end %>
<% end %>
<% elsif article.published %>
<% elsif article.published && !article.scheduled? %>
<a href="<%= article.path %>/manage" class="crayons-btn crayons-btn--ghost crayons-btn--s" aria-label="<%= t("views.dashboard.article.manage.aria_label", title: article.title) %>"><%= t("views.dashboard.article.manage.text") %></a>
<% elsif @user == article.user %>
<a href="<%= article.path %>/delete_confirm" data-no-instant class="crayons-btn crayons-btn--ghost-danger crayons-btn--s"><%= t("views.dashboard.article.delete") %></a>

View file

@ -0,0 +1,20 @@
module Articles
class PublishWorker
include Sidekiq::Worker
sidekiq_options queue: :medium_priority, lock: :until_executing
def perform
# find published articles for which notifications were not set yet (so the context notifications were not created)
published_articles = Article.where(published: true, published_at: 30.minutes.ago..Time.current)
.where.missing(:context_notifications_published)
published_articles.each do |article|
# send slack notifications
Slack::Messengers::ArticlePublished.call(article: article)
# create mentions and notifications
# Send notifications to any mentioned users, followed by any users who follow the article's author.
Notification.send_to_mentioned_users_and_followers(article)
end
end
end
end

View file

@ -21,6 +21,8 @@ en:
must_not_have_spaces: must not have spaces
published: Published
unique_url: has already been taken. Email %{email} for further details.
future_or_current_published_at: only future or current published_at allowed when publishing an article
immutable_published_at: updating published_at for articles that have already been published is not allowed
mention_too_many:
one: You cannot mention more than %{count} users in a post!
other: You cannot mention more than %{count} users in a post!

View file

@ -21,6 +21,8 @@ fr:
must_not_have_spaces: must not have spaces
published: Published
unique_url: has already been taken. Email %{email} for further details.
future_or_current_published_at: only future or current published_at allowed when publishing an article
immutable_published_at: updating published_at for articles that have already been published is not allowed
mention_too_many:
one: You cannot mention more than %{count} users in a post!
other: You cannot mention more than %{count} users in a post!

View file

@ -48,6 +48,10 @@ en:
aria_label: Save to reading list
initial: Save
success: Saved
scheduled:
subtitle: Scheduled Post.
text: " This URL is public but secret, so share at your own discretion."
edit: Click to edit
series:
subtitle: "%{slug} (%{size})"
size:

View file

@ -73,6 +73,10 @@ fr:
subtitle: Unpublished Post.
text: " This URL is public but secret, so share at your own discretion."
edit: Click to edit
scheduled:
subtitle: Scheduled Post.
text: " This URL is public but secret, so share at your own discretion."
edit: Click to edit
co_authors_html: with %{names}
crossposted:
text_html: Originally published at %{url} %{on}

View file

@ -45,6 +45,8 @@ en:
series: 'Series:'
series_html: "<strong>Series:</strong> %{series}"
draft: Draft
scheduled: Scheduled
scheduled_at: 'Scheduled:'
reactions:
title: Reactions
icon: Reactions

View file

@ -45,6 +45,8 @@ fr:
series: 'Series:'
series_html: "<strong>Series:</strong> %{series}"
draft: Draft
scheduled: Scheduled
scheduled_at: 'Scheduled:'
reactions:
title: Reactions
icon: Reactions

View file

@ -146,3 +146,7 @@ capture_query_stats:
description: "Collects Postgres query stats for PGHero (runs every 5 minutes)"
cron: "*/5 * * * *"
class: "CaptureQueryStatsWorker"
notify_about_published_articles:
description: "Sends notifications about the new published articles (runs every minute)"
cron: "*/5 * * * *"
class: "Articles::PublishWorker"

View file

@ -0,0 +1,7 @@
module DataUpdateScripts
class AddScheduleArticlesFeatureFlag
def run
FeatureFlag.add(:schedule_articles)
end
end
end

View file

@ -45,6 +45,12 @@ RSpec.describe ArticleDecorator, type: :decorator do
expected_result = "/#{article.username}/#{article.slug}?preview=#{article.password}"
expect(article.current_state_path).to eq(expected_result)
end
it "returns the path /:username/:slug?:password when scheduled" do
article = create_article(published: true, published_at: Date.tomorrow)
expected_result = "/#{article.username}/#{article.slug}?preview=#{article.password}"
expect(article.current_state_path).to eq(expected_result)
end
end
describe "has_recent_comment_activity?" do

View file

@ -19,6 +19,7 @@ FactoryBot.define do
with_user_subscription_tag { false }
with_title { true }
with_collection { nil }
past_published_at { Time.current }
end
co_author_ids { [] }
association :user, factory: :user, strategy: :create
@ -70,4 +71,13 @@ FactoryBot.define do
trait :with_discussion_lock do
after(:create) { |article| create(:discussion_lock, locking_user_id: article.user_id, article_id: article.id) }
end
# NOTE: [@lightalloy] This trait is used to create articles published in the past (with past published_at)
# we can't do it directly because of the validation Article#has_correct_published_at?
# TODO: [@lightalloy] Remove the trait and its usage when has_correct_published_at? will be removed
trait :past do
after(:create) do |article, evaluator|
article.update_column(:published_at, evaluator.past_published_at)
end
end
end

View file

@ -0,0 +1,24 @@
require "rails_helper"
require Rails.root.join(
"lib/data_update_scripts/20220613093333_add_schedule_articles_feature_flag.rb",
)
describe DataUpdateScripts::AddScheduleArticlesFeatureFlag do
after do
FeatureFlag.remove(:schedule_articles)
end
it "adds the :schedule_articles flag" do
expect do
described_class.new.run
end.to change { FeatureFlag.exist?(:schedule_articles) }.from(false).to(true)
end
it "works if the flag is already available" do
FeatureFlag.add(:schedule_articles)
expect do
described_class.new.run
end.not_to change { FeatureFlag.exist?(:schedule_articles) }
end
end

View file

@ -52,16 +52,16 @@ RSpec.describe AbExperiment::GoalConversionHandler do
end
it "records a conversion", :aggregate_failures do
create(:article, published_at: 2.days.ago, user_id: user.id)
create(:article, :past, past_published_at: 2.days.ago, user_id: user.id)
handler.call
expect(FieldTest::Event.last.field_test_membership.participant_id).to eq(user.id.to_s)
expect(FieldTest::Event.last.name).to eq(goal)
end
it "records weekly post publishing conversions", :aggregate_failures do
create(:article, published_at: 2.days.ago, user_id: user.id)
create(:article, published_at: 3.days.ago, user_id: user.id)
create(:article, published_at: 13.days.ago, user_id: user.id)
create(:article, :past, past_published_at: 2.days.ago, user_id: user.id)
create(:article, :past, past_published_at: 3.days.ago, user_id: user.id)
create(:article, :past, past_published_at: 13.days.ago, user_id: user.id)
handler.call
@ -75,10 +75,10 @@ RSpec.describe AbExperiment::GoalConversionHandler do
end
it "records a conversion when they post 4 within a week" do
create(:article, published_at: 25.hours.ago, user_id: user.id)
create(:article, published_at: 49.hours.ago, user_id: user.id)
create(:article, published_at: 73.hours.ago, user_id: user.id)
create(:article, published_at: 97.hours.ago, user_id: user.id)
create(:article, :past, past_published_at: 25.hours.ago, user_id: user.id)
create(:article, :past, past_published_at: 49.hours.ago, user_id: user.id)
create(:article, :past, past_published_at: 73.hours.ago, user_id: user.id)
create(:article, :past, past_published_at: 97.hours.ago, user_id: user.id)
handler.call

View file

@ -484,23 +484,33 @@ RSpec.describe Article, type: :model do
end
it "sets published_at from a valid frontmatter date" do
date = (Date.current - 5.days).strftime("%d/%m/%Y")
date = (Date.current + 5.days).strftime("%d/%m/%Y")
article_with_date = build(:article, with_date: true, date: date, published_at: nil)
expect(article_with_date.valid?).to be(true)
expect(article_with_date.published_at.strftime("%d/%m/%Y")).to eq(date)
end
it "rejects future dates set from frontmatter" do
invalid_article = build(:article, with_date: true, date: Date.tomorrow.strftime("%d/%m/%Y"), published_at: nil)
expect(invalid_article.valid?).to be(false)
expect(invalid_article.errors[:date_time])
.to include("must be entered in DD/MM/YYYY format with current or past date")
it "sets published_at from frontmatter" do
published_at = (Date.current + 10.days).strftime("%d/%m/%Y %H:%M")
body_markdown = "---\ntitle: Title\npublished: false\npublished_at: #{published_at}\ndescription:\ntags: heytag
\n---\n\nHey this is the article"
article_with_published_at = build(:article, body_markdown: body_markdown)
expect(article_with_published_at.valid?).to be(true)
expect(article_with_published_at.published_at.strftime("%d/%m/%Y %H:%M")).to eq(published_at)
end
it "rejects future dates even when it's published at" do
article.published_at = Date.tomorrow
expect(article.valid?).to be(false)
expect(article.errors[:date_time]).to include("must be entered in DD/MM/YYYY format with current or past date")
it "doesn't allow past published_at when publishing on create" do
article2 = build(:article, published_at: 10.days.ago, published: true)
expect(article2.valid?).to be false
expect(article2.errors[:published_at])
.to include("only future or current published_at allowed when publishing an article")
end
it "doesn't allow updating published_at if an article has already been published" do
article.published_at = (Date.current + 10.days).strftime("%d/%m/%Y %H:%M")
expect(article.valid?).to be false
expect(article.errors[:published_at])
.to include("updating published_at for articles that have already been published is not allowed")
end
end
@ -1167,8 +1177,9 @@ RSpec.describe Article, type: :model do
sidekiq_perform_enqueued_jobs(only: Slack::Messengers::Worker)
end
it "queues a slack message to be sent for a new published article" do
sidekiq_assert_enqueued_jobs(1, only: Slack::Messengers::Worker) do
# slack messages will be queued in a publish worker
it "doesn't queue a slack message to be sent for a new published article" do
sidekiq_assert_no_enqueued_jobs(only: Slack::Messengers::Worker) do
create(:article, user: user, published: true, published_at: Time.current)
end
end

View file

@ -9,7 +9,8 @@ RSpec.describe Articles::ActiveThreadsQuery, type: :query do
context "when time_ago is latest" do
it "returns the latest article with a good score", :aggregate_failures do
article = create(:article, tags: "discuss", score: described_class::MINIMUM_SCORE + 1)
create(:article, published_at: 2.days.ago, tags: "discuss", score: described_class::MINIMUM_SCORE + 1)
create(:article, :past, past_published_at: 2.days.ago, tags: "discuss",
score: described_class::MINIMUM_SCORE + 1)
result = described_class.call(tags: "discuss", time_ago: "latest", count: 10)
expect(result.length).to eq(2)
@ -28,11 +29,11 @@ RSpec.describe Articles::ActiveThreadsQuery, type: :query do
context "when given a precise time" do
it "returns article ordered_by comment_count based on time", :aggregate_failures do
time = 2.days.ago
article = create(:article, comments_count: 20, published_at: time, tags: "discuss",
score: described_class::MINIMUM_SCORE)
article = create(:article, :past, comments_count: 20, past_published_at: time, tags: "discuss",
score: described_class::MINIMUM_SCORE)
create(:article, comments_count: 10, tags: "discuss", score: described_class::MINIMUM_SCORE)
create(:article, published_at: time - 2.days, comments_count: 30, tags: "discuss",
score: described_class::MINIMUM_SCORE)
create(:article, :past, past_published_at: time - 2.days, comments_count: 30, tags: "discuss",
score: described_class::MINIMUM_SCORE)
result = described_class.call(tags: "discuss", time_ago: time, count: 10)
expect(result.length).to eq(2)
@ -41,8 +42,8 @@ RSpec.describe Articles::ActiveThreadsQuery, type: :query do
it "returns any article when no higher-quality article could be found", :aggregate_failures do
time = 2.days.ago
article = create(:article, comments_count: 20, published_at: time - 5.days, tags: "discuss",
score: described_class::MINIMUM_SCORE)
article = create(:article, :past, comments_count: 20, past_published_at: time - 5.days, tags: "discuss",
score: described_class::MINIMUM_SCORE)
create(:article, comments_count: 10, tags: "discuss", score: described_class::MINIMUM_SCORE - 10)
result = described_class.call(tags: "discuss", time_ago: time, count: 10)

View file

@ -12,6 +12,12 @@ RSpec.describe Homepage::ArticlesQuery, type: :query do
expect(described_class.call.ids).to eq([article.id])
end
it "does not return scheduled articles" do
scheduled_article = create(:article, published_at: Date.current + 5.days)
expect(described_class.call.ids).not_to include(scheduled_article.id)
end
it "does not return draft articles" do
article = create(:article, published: false, published_at: nil)

View file

@ -41,7 +41,7 @@ RSpec.describe "/admin", type: :request do
xit { is_expected.to include("Apr 23") }
xit "displays correct number of posts from past week" do
create(:article, published_at: Time.zone.today)
create(:article, published_at: Time.current)
create(:article, published_at: 1.day.ago)
create(:article, published_at: 7.days.ago)
create(:article, published_at: 8.days.ago)
@ -51,7 +51,7 @@ RSpec.describe "/admin", type: :request do
end
it "displays correct number of comments from past week" do
create(:comment, created_at: Time.zone.today)
create(:comment, created_at: Time.current)
create(:comment, created_at: 1.day.ago)
create(:comment, created_at: 8.days.ago)
get admin_path
@ -60,7 +60,7 @@ RSpec.describe "/admin", type: :request do
end
it "displays correct number of reactions from past week" do
create(:reaction, created_at: Time.zone.today)
create(:reaction, created_at: Time.current)
create(:reaction, created_at: 3.days.ago)
create(:reaction, created_at: 2.weeks.ago)
get admin_path
@ -69,7 +69,7 @@ RSpec.describe "/admin", type: :request do
end
it "displays correct number of new members from past week" do
create(:user, registered_at: Time.zone.today)
create(:user, registered_at: Time.current)
create(:user, registered_at: 2.days.ago)
create(:user, registered_at: 10.days.ago)
get admin_path
@ -78,7 +78,7 @@ RSpec.describe "/admin", type: :request do
end
it "does not display data from previous weeks", :aggregate_failures do
create(:article, published_at: 8.days.ago)
create(:article, :past, past_published_at: 8.days.ago)
create(:comment, created_at: 2.weeks.ago)
create(:reaction, created_at: 1.month.ago)
create(:user, registered_at: 10.days.ago)
@ -91,10 +91,10 @@ RSpec.describe "/admin", type: :request do
end
it "does not display data from today", :aggregate_failures do
create(:article, published_at: Time.zone.today)
create(:comment, created_at: Time.zone.today)
create(:reaction, created_at: Time.zone.today)
create(:user, registered_at: Time.zone.today)
create(:article, published_at: Time.current)
create(:comment, created_at: Time.current)
create(:reaction, created_at: Time.current)
create(:user, registered_at: Time.current)
get admin_path
expect(body).to include "0</span> Posts"

View file

@ -1075,27 +1075,6 @@ RSpec.describe "Api::V0::Articles", type: :request do
expect(article.reload.published).to be(true)
end
it "sends a notification when the article gets published" do
expect(article.published).to be(false)
allow(Notification).to receive(:send_to_followers)
put_article(body_markdown: "Yo ho ho", published: true)
expect(response).to have_http_status(:ok)
expect(Notification).to have_received(:send_to_followers).with(article, "Published").once
end
it "only sends a notification the first time the article gets published" do
expect(article.published).to be(false)
allow(Notification).to receive(:send_to_followers)
put_article(body_markdown: "Yo ho ho", published: true)
expect(response).to have_http_status(:ok)
article.update_columns(published: false)
put_article(published: true)
expect(response).to have_http_status(:ok)
expect(Notification).to have_received(:send_to_followers).with(article, "Published").once
end
it "does not update the editing time when updated before publication" do
article.update_columns(edited_at: nil)
expect(article.published).to be(false)
@ -1108,7 +1087,7 @@ RSpec.describe "Api::V0::Articles", type: :request do
end
it "updates the editing time when updated after publication" do
article.update_columns(published: true)
article.update_columns(published: true, published_at: Time.current)
put_article(
title: Faker::Book.title,
body_markdown: "Yo ho ho",
@ -1141,7 +1120,7 @@ RSpec.describe "Api::V0::Articles", type: :request do
it "updates the editing time when updated after publication if the owner is an admin" do
user.add_role(:super_admin)
article.update_columns(edited_at: nil, published: true)
article.update_columns(edited_at: nil, published: true, published_at: Time.current)
put_article(
title: Faker::Book.title,
body_markdown: "Yo ho ho",

View file

@ -1089,27 +1089,6 @@ RSpec.describe "Api::V1::Articles", type: :request do
expect(article.reload.published).to be(true)
end
it "sends a notification when the article gets published" do
expect(article.published).to be(false)
allow(Notification).to receive(:send_to_followers)
put_article(body_markdown: "Yo ho ho", published: true)
expect(response).to have_http_status(:ok)
expect(Notification).to have_received(:send_to_followers).with(article, "Published").once
end
it "only sends a notification the first time the article gets published" do
expect(article.published).to be(false)
allow(Notification).to receive(:send_to_followers)
put_article(body_markdown: "Yo ho ho", published: true)
expect(response).to have_http_status(:ok)
article.update_columns(published: false)
put_article(published: true)
expect(response).to have_http_status(:ok)
expect(Notification).to have_received(:send_to_followers).with(article, "Published").once
end
it "does not update the editing time when updated before publication" do
article.update_columns(edited_at: nil)
expect(article.published).to be(false)

View file

@ -3,13 +3,13 @@ require "rails_helper"
RSpec.describe "ArticlesCreate", type: :request do
let(:user) { create(:user, :org_member) }
let(:template) { file_fixture("article_published.txt").read }
let(:new_title) { "NEW TITLE #{rand(100)}" }
before do
sign_in user
end
it "creates ordinary article with proper params" do
new_title = "NEW TITLE #{rand(100)}"
post "/articles", params: {
article: { title: new_title, body_markdown: "Yo ho ho#{rand(100)}", tag_list: "yo" }
}
@ -17,7 +17,6 @@ RSpec.describe "ArticlesCreate", type: :request do
end
it "properly downcase tags" do
new_title = "NEW TITLE #{rand(100)}"
post "/articles", params: {
article: { title: new_title, body_markdown: "Yo ho ho#{rand(100)}", tag_list: "What" }
}
@ -114,4 +113,66 @@ RSpec.describe "ArticlesCreate", type: :request do
expect(response.headers["Retry-After"]).to eq(expected_retry_after)
end
end
context "when setting published_at in editor v2" do
let(:tomorrow) { 1.day.from_now }
let(:attributes) do
{ title: new_title, body_markdown: "Yo ho ho#{rand(100)}",
published_at: "#{tomorrow.strftime('%Y-%m-%d')} 18:00" }
end
it "sets published_at according to the timezone" do
attributes[:timezone] = "Europe/Moscow"
post "/articles", params: { article: attributes }
a = Article.find_by(title: new_title)
published_at_utc = a.published_at.in_time_zone("UTC").strftime("%m/%d/%Y %H:%M")
expect(published_at_utc).to eq("#{tomorrow.strftime('%m/%d/%Y')} 15:00")
end
# crossing the date line
it "sets published_at for another timezone" do
attributes[:timezone] = "Pacific/Honolulu"
post "/articles", params: { article: attributes }
a = Article.find_by(title: new_title)
published_at_utc = a.published_at.in_time_zone("UTC").strftime("%m/%d/%Y %H:%M")
expect(published_at_utc).to eq("#{(tomorrow + 1.day).strftime('%m/%d/%Y')} 04:00")
end
end
context "when setting published_at from editor v1" do
it "sets current published_at when publishing and published_at not specified" do
body_markdown = "---\ntitle: super-article\npublished: true\ndescription:\ntags: heytag
\n---\n\nHey this is the article"
post "/articles", params: { article: { body_markdown: body_markdown } }
a = Article.find_by(title: "super-article")
expect(a.published_at).to be_within(1.minute).of(Time.current)
end
it "doesn't set published_at for drafts when published_at is not specified" do
body_markdown = "---\ntitle: super-article\npublished: false\ndescription:\ntags: heytag
\n---\n\nHey this is the article"
post "/articles", params: { article: { body_markdown: body_markdown } }
a = Article.find_by(title: "super-article")
expect(a.published_at).to be_nil
end
it "sets published_at from frontmatter" do
published_at = 10.days.from_now.in_time_zone("UTC")
body_markdown = "---\ntitle: super-article\npublished: true\ndescription:\ntags: heytag
\npublished_at: #{published_at.strftime('%Y-%m-%d %H:%M %z')}\n---\n\nHey this is the article"
post "/articles", params: { article: { body_markdown: body_markdown } }
a = Article.find_by(title: "super-article")
expect(a.published_at).to be_within(1.minute).of(published_at)
end
it "sets published_at with timezone from frontmatter" do
published_at = 10.days.from_now.in_time_zone("America/Caracas")
body_markdown = "---\ntitle: super-article\npublished: true\ndescription:\ntags: heytag
\npublished_at: #{published_at.strftime('%Y-%m-%d %H:%M %z')}\n---\n\nHey this is the article"
post "/articles", params: { article: { body_markdown: body_markdown } }
a = Article.find_by(title: "super-article")
# binding.pry
expect(a.published_at).to be_within(1.minute).of(published_at)
end
end
end

View file

@ -59,6 +59,27 @@ RSpec.describe "ArticlesShow", type: :request do
end
end
describe "GET /:username/:slug (scheduled)" do
let(:scheduled_article) { create(:article, published: true, published_at: Date.tomorrow) }
let(:query_params) { "?preview=#{scheduled_article.password}" }
let(:scheduled_article_path) { scheduled_article.path + query_params }
it "renders a scheduled article with the article password" do
get scheduled_article_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(scheduled_article.title)
end
it "doesn't show edit link when user is not signed in" do
get scheduled_article_path
expect(response.body).not_to include("Click to edit")
end
it "renders 404 for a scheduled article w/o article password" do
expect { get scheduled_article.path }.to raise_error(ActiveRecord::RecordNotFound)
end
end
it "renders the proper organization for an article when one is present" do
get organization.path
expect(response_json).to include(

View file

@ -311,6 +311,16 @@ RSpec.describe "Articles", type: :request do
expect(response.body).to include("Manage Your Post")
end
it "returns unauthorized for a draft" do
draft = create(:article, published: false, user: user)
expect { get "#{draft.path}/manage" }.to raise_error(Pundit::NotAuthorizedError)
end
it "returns unauthorized for a scheduled article" do
scheduled_article = create(:article, published: true, user: user, published_at: 1.day.from_now)
expect { get "#{scheduled_article.path}/manage" }.to raise_error(Pundit::NotAuthorizedError)
end
it "returns unauthorized if the user is not the author" do
second_user = create(:user)
article = create(:article, user: second_user)

View file

@ -129,20 +129,11 @@ RSpec.describe "ArticlesUpdate", type: :request do
expect(article.collection).to be_nil
end
it "creates a notification job if published the first time" do
draft = create(:article, published: false, published_at: nil, user_id: user.id)
sidekiq_assert_enqueued_with(job: Notifications::NotifiableActionWorker) do
put "/articles/#{draft.id}", params: {
article: { published: true, body_markdown: "blah" }
}
end
end
it "does not create a notification job if published the second time" do
article.update_column(:published, false)
sidekiq_assert_not_enqueued_with(job: Notifications::NotifiableActionWorker) do
put "/articles/#{article.id}", params: {
article: { published: true, body_markdown: "blah" }
article: { published: true, body_markdown: "blah" }
}
end
end
@ -167,4 +158,88 @@ RSpec.describe "ArticlesUpdate", type: :request do
expect(response).to redirect_to "#{article.path}/edit"
expect(article.reload.video_thumbnail_url).to include "https://i.imgur.com/HPiu7N4.jpg"
end
context "when setting published_at in editor v2" do
let(:tomorrow) { 1.day.from_now }
let(:published_at) { "#{tomorrow.strftime('%d.%m.%Y')} 18:00" }
let(:attributes) do
{ title: "NEW TITLE #{rand(100)}", body_markdown: "Yo ho ho#{rand(100)}", published_at: published_at }
end
# scheduled => scheduled
it "updates published_at from scheduled to scheduled" do
article.update_column(:published_at, 3.days.from_now)
attributes[:timezone] = "Europe/Moscow"
put "/articles/#{article.id}", params: { article: attributes }
article.reload
published_at_utc = article.published_at.in_time_zone("UTC").strftime("%m/%d/%Y %H:%M")
expect(published_at_utc).to eq("#{tomorrow.strftime('%m/%d/%Y')} 15:00")
end
# draft => scheduled
it "sets published_at according to the timezone when updating draft => scheduled" do
draft = create(:article, published: false, user_id: user.id)
attributes[:published] = true
attributes[:published_at] = "#{tomorrow.strftime('%d/%m/%Y')} 18:00"
attributes[:timezone] = "America/Mexico_City"
put "/articles/#{draft.id}", params: { article: attributes }
draft.reload
published_at_utc = draft.published_at.in_time_zone("UTC").strftime("%m/%d/%Y %H:%M")
expect(published_at_utc).to eq("#{tomorrow.strftime('%m/%d/%Y')} 23:00")
expect(draft.published).to be true
end
it "doesn't update published_at when published => published" do
published_at = DateTime.parse("2022-01-01 15:00 -0400")
article.update_column(:published_at, published_at)
attributes[:timezone] = "Europe/Moscow"
put "/articles/#{article.id}", params: { article: attributes }
article.reload
expect(article.published_at).to eq(published_at)
end
end
context "when setting published_at in editor v1" do
it "updates published_at from scheduled to scheduled with timezone" do
published_at = 3.days.from_now.in_time_zone("Asia/Dhaka")
article.update_columns(published: true, published_at: 1.day.from_now)
body_markdown = "---\ntitle: super-article\npublished: true\ndescription:\ntags: heytag
\npublished_at: #{published_at.strftime('%Y-%m-%d %H:%M %z')}\n---\n\nHey this is the article"
put "/articles/#{article.id}", params: { article: { body_markdown: body_markdown } }
article.reload
expect(article.published_at).to be_within(1.minute).of(published_at)
end
it "doesn't update published_at when published => published" do
published_at = DateTime.parse("2022-05-23 18:00 +0030")
article.update_columns(published: true, published_at: published_at)
body_markdown = "---\ntitle: super-article\npublished: true\ndescription:\ntags: heytag
\npublished_at: #{1.day.from_now.strftime('%Y-%m-%d %H:%M %z')}\n---\n\nHey this is the article"
put "/articles/#{article.id}", params: { article: { body_markdown: body_markdown } }
article.reload
expect(article.published_at).to eq(published_at)
end
it "sets current published_at when draft => published and no published_at specified" do
draft = create(:article, published: false, user_id: user.id, published_at: nil)
body_markdown = "---\ntitle: super-article\npublished: true\ndescription:\ntags: heytag
\n---\n\nHey this is the article"
put "/articles/#{draft.id}", params: { article: { body_markdown: body_markdown } }
draft.reload
expect(draft.published_at).to be_within(1.minute).of(Time.current)
end
it "allows to set past published_at when publishing with date and no published_at for exported articles" do
date = "2022-05-02 19:00:30 UTC"
draft = create(:article, published: false, user_id: user.id, published_from_feed: true, published_at: nil)
body_markdown = "---\ntitle: super-article\npublished: true\ndescription:\ntags: heytag
\ndate: #{date}---\n\nHey this is the article"
put "/articles/#{draft.id}", params: { article: { body_markdown: body_markdown } }
draft.reload
expect(draft.published).to be true
expect(draft.published_at).to be_within(1.minute).of(DateTime.parse(date))
end
end
end

View file

@ -6,6 +6,7 @@ RSpec.describe "Dashboards", type: :request do
let(:super_admin) { create(:user, :super_admin) }
let(:article) { create(:article, user: user) }
let(:unpublished_article) { create(:article, user: user, published: false) }
let(:scheduled_article) { create(:article, user: user, published_at: 2.days.from_now) }
let(:organization) { create(:organization) }
describe "GET /dashboard" do
@ -41,6 +42,24 @@ RSpec.describe "Dashboards", type: :request do
expect(response.body).to include "Delete"
end
it "renders the draft state indicator" do
unpublished_article
get "/dashboard"
expect(response.body).to include "Draft"
end
it "renders scheduled state indicator" do
scheduled_article
get "/dashboard"
expect(response.body).to include "Scheduled"
end
it "renders the delete button for scheduled article" do
scheduled_article
get "/dashboard"
expect(response.body).to include "Delete"
end
it "renders subscriptions for articles with subscriptions" do
allow(user).to receive(:has_role?).and_call_original
allow(user).to receive(:has_role?).with(:restricted_liquid_tag,

View file

@ -28,7 +28,7 @@ RSpec.describe "Sitemaps", type: :request do
it "sends a surrogate key (for Fastly's user)" do
articles = create_list(:article, 4)
included_articles = articles.first(3)
included_articles.each { |a| a.update(published_at: "2020-03-07T00:27:30Z", score: 10) }
included_articles.each { |a| a.update_columns(published_at: "2020-03-07T00:27:30Z", score: 10) }
get "/sitemap-Mar-2020.xml"

View file

@ -13,6 +13,7 @@ RSpec.describe "Stories::TaggedArticlesIndex", type: :request do
let(:tag) { create(:tag) }
let(:org) { create(:organization) }
let(:article) { create(:article, tags: tag.name, score: 5) }
let(:unsupported_tag) { create(:tag, supported: false) }
before do
stub_const("Stories::TaggedArticlesController::SIGNED_OUT_RECORD_COUNT", 10)
@ -62,8 +63,8 @@ RSpec.describe "Stories::TaggedArticlesIndex", type: :request do
end
it "renders page when tag is not supported but has at least one approved article" do
unsupported_tag = create(:tag, supported: false)
create(:article, published: true, approved: true, tags: unsupported_tag, published_at: 5.years.ago)
create(:article, :past, published: true, approved: true, tags: unsupported_tag,
past_published_at: 5.years.ago)
get "/t/#{unsupported_tag.name}/top/week"
@ -85,6 +86,11 @@ RSpec.describe "Stories::TaggedArticlesIndex", type: :request do
expect { get "/t/#{tag.name}" }.to raise_error(ActiveRecord::RecordNotFound)
end
it "renders not found if there are approved but scheduled posts" do
create(:article, published: true, approved: true, tags: unsupported_tag, published_at: 1.hour.from_now)
expect { get "/t/#{unsupported_tag.name}" }.to raise_error(ActiveRecord::RecordNotFound)
end
it "renders normal page if no articles but tag is supported" do
Article.destroy_all
expect { get "/t/#{tag.name}" }.not_to raise_error

View file

@ -26,6 +26,12 @@ RSpec.describe "StoriesIndex", type: :request do
renders_proper_sidebar(navigation_link)
end
it "doesn't render a featured scheduled article" do
article = create(:article, featured: true, published_at: 1.hour.from_now)
get "/"
expect(response.body).not_to include(CGI.escapeHTML(article.title))
end
def renders_proper_description
expect(response.body).to include(Settings::Community.community_description)
end

View file

@ -10,7 +10,7 @@ RSpec.describe Admin::ChartsData, type: :service do
end
it "returns proper previous period number" do
create_list(:article, 2, published_at: 8.days.ago)
create_list(:article, 2, :past, past_published_at: 8.days.ago)
expect(described_class.new.call.first.third).to eq(2)
end
@ -39,8 +39,7 @@ RSpec.describe Admin::ChartsData, type: :service do
end
it "ignores today" do
create(:article, published_at: Time.zone.today)
create(:article, :past, past_published_at: Time.zone.today)
expect(described_class.new.call.first.second).to eq(0)
end

View file

@ -67,5 +67,25 @@ RSpec.describe Articles::Attributes, type: :service do
attrs = described_class.new({ title: "title" }, user).for_update(update_edited_at: false)
expect(attrs[:edited_at]).to be_falsey
end
it "sets published_at if update_published_at is true" do
attrs = described_class.new({ title: "title" }, user).for_update(update_published_at: true)
expect(attrs[:published_at]).to be_truthy
end
it "doesn't set published_at if update_published_at is false" do
attrs = described_class.new({ title: "title" }, user).for_update(update_published_at: false)
expect(attrs[:published_at]).to be_falsey
end
it "sets published_at correctly" do
attrs = described_class.new({ title: "title", published_at: "2022-04-25" }, user).for_update
expect(attrs[:published_at]).to eq(DateTime.new(2022, 4, 25))
end
it "doesn't set published_at if it's nil and update_published_at is false" do
attrs = described_class.new({ title: "title", published_at: nil }, user).for_update
expect(attrs[:published_at]).to be_nil
end
end
end

View file

@ -128,13 +128,9 @@ RSpec.describe Articles::Builder, type: :service do
context "when user_editor_v1" do
let(:user) { create(:user) }
let(:correct_attributes) do
body = "---\ntitle: \npublished: false\ndescription: " \
"\ntags: \n# cover_image: https://direct_url_to_image.jpg" \
"\n# Use a ratio of 100:42 for best results.\n---\n\n"
let(:correct_attributes) do
{
body_markdown: body,
processed_html: "",
user_id: user.id
}
@ -142,10 +138,23 @@ RSpec.describe Articles::Builder, type: :service do
it "initializes an article with the correct attributes and does not need authorization" do
user.setting.update(editor_version: "v1")
allow(FeatureFlag).to receive(:enabled?).with(:schedule_articles).and_return(true)
subject, needs_authorization = described_class.call(user, tag, prefill)
expect(subject).to be_an_instance_of(Article)
expect(subject).to have_attributes(correct_attributes)
date = Time.current.strftime("%Y-%m-%d")
zone = Time.current.strftime("%z")
body_start = "---\ntitle: \npublished: false\ndescription: " \
"\ntags: \n# cover_image: https://direct_url_to_image.jpg" \
"\n# Use a ratio of 100:42 for best results.\n# published_at: #{date} "
expect(subject.body_markdown).to start_with(body_start)
expect(subject.body_markdown).to end_with("#{zone}\n---\n\n")
expect(needs_authorization).to be false
end
end

View file

@ -19,20 +19,6 @@ RSpec.describe Articles::Creator, type: :service do
expect(article).to be_persisted
end
it "schedules a job" do
valid_attributes[:published] = true
sidekiq_assert_enqueued_with(job: Notifications::NotifiableActionWorker) do
described_class.call(user, valid_attributes)
end
end
it "delegates to the Mentions::CreateAll service" do
valid_attributes[:published] = true
allow(Mentions::CreateAll).to receive(:call)
article = described_class.call(user, valid_attributes)
expect(Mentions::CreateAll).to have_received(:call).with(article)
end
it "creates a notification subscription" do
expect do
described_class.call(user, valid_attributes)
@ -62,18 +48,6 @@ RSpec.describe Articles::Creator, type: :service do
expect(article.errors.size).to eq(1)
end
it "doesn't schedule a job" do
sidekiq_assert_no_enqueued_jobs only: Notifications::NotifiableActionWorker do
described_class.call(user, invalid_body_attributes)
end
end
it "doesn't delegate to the Mentions::CreateAll service" do
allow(Mentions::CreateAll).to receive(:call)
article = described_class.call(user, invalid_body_attributes)
expect(Mentions::CreateAll).not_to have_received(:call).with(article)
end
it "doesn't create a notification subscription" do
expect do
described_class.call(user, invalid_body_attributes)

View file

@ -6,11 +6,11 @@ RSpec.describe Articles::Feeds::Basic, type: :service do
let(:unique_tag_name) { "foo" }
let!(:article) { create(:article, hotness_score: 10) }
let!(:hot_story) do
create(:article, hotness_score: 1000, score: 1000, published_at: 3.hours.ago, user_id: second_user.id)
create(:article, :past, hotness_score: 1000, score: 1000, past_published_at: 3.hours.ago, user_id: second_user.id)
end
let!(:old_story) { create(:article, hotness_score: 500, published_at: 3.days.ago, tags: unique_tag_name) }
let!(:old_story) { create(:article, :past, hotness_score: 500, past_published_at: 3.days.ago, tags: unique_tag_name) }
let!(:low_scoring_article) { create(:article, score: -1000) }
let!(:month_old_story) { create(:article, published_at: 1.month.ago) } # rubocop:disable RSpec/LetSetup
let!(:month_old_story) { create(:article, :past, past_published_at: 1.month.ago) } # rubocop:disable RSpec/LetSetup
context "without a user" do
let(:feed) { described_class.new(user: nil, number_of_articles: 100, page: 1) }

View file

@ -6,9 +6,9 @@ RSpec.describe Articles::Feeds::LargeForemExperimental, type: :service do
let!(:feed) { described_class.new(user: user, number_of_articles: 100, page: 1) }
let!(:article) { create(:article) }
let!(:hot_story) do
create(:article, hotness_score: 1000, score: 1000, published_at: 3.hours.ago, user_id: second_user.id)
create(:article, :past, hotness_score: 1000, score: 1000, past_published_at: 3.hours.ago, user_id: second_user.id)
end
let!(:old_story) { create(:article, published_at: 3.days.ago) }
let!(:old_story) { create(:article, :past, past_published_at: 3.days.ago) }
let!(:low_scoring_article) { create(:article, score: -1000) }
describe "#featured_story_and_default_home_feed" do
@ -179,7 +179,7 @@ RSpec.describe Articles::Feeds::LargeForemExperimental, type: :service do
context "when no hot stories or recently published articles" do
before do
Article.delete_all
create(:article, hotness_score: 0, score: 0, published_at: 3.days.ago)
create(:article, :past, hotness_score: 0, score: 0, past_published_at: 3.days.ago)
end
it "still returns articles" do

View file

@ -2,10 +2,10 @@ require "rails_helper"
RSpec.describe Articles::Feeds::Latest, type: :service do
let!(:hot_article) do
create(:article, hotness_score: 1000, score: 1000, published_at: 3.hours.ago, user: create(:user))
create(:article, :past, hotness_score: 1000, score: 1000, past_published_at: 3.hours.ago, user: create(:user))
end
let!(:newest_article) { create(:article, published_at: 1.hour.ago) }
let!(:month_old_article) { create(:article, published_at: 1.month.ago) }
let!(:newest_article) { create(:article, :past, past_published_at: 1.hour.ago) }
let!(:month_old_article) { create(:article, :past, past_published_at: 1.month.ago) }
let!(:low_scoring_article) { create(:article, score: -1000) }
it "returns articles ordered by publishing date descending" do

View file

@ -2,11 +2,11 @@ require "rails_helper"
RSpec.describe Articles::Feeds::Timeframe, type: :service do
let!(:hot_article) do
create(:article, hotness_score: 1000, score: 1000, published_at: 3.hours.ago, user: create(:user))
create(:article, :past, hotness_score: 1000, score: 1000, past_published_at: 3.hours.ago, user: create(:user))
end
let!(:low_scoring_article) { create(:article, score: -1000) }
let!(:moderately_high_scoring_article) { create(:article, score: 20) }
let!(:month_old_article) { create(:article, published_at: 1.month.ago) }
let!(:month_old_article) { create(:article, :past, past_published_at: 1.month.ago) }
it "returns correct articles ordered by score", :aggregate_failures do
result = described_class.call("week")

View file

@ -52,25 +52,66 @@ RSpec.describe Articles::Updater, type: :service do
context "when an article is updated and published the first time" do
before { attributes[:published] = true }
it "enqueues a job to send a notification" do
sidekiq_assert_enqueued_with(job: Notifications::NotifiableActionWorker) do
described_class.call(user, draft, attributes)
end
# it "enqueues a job to send a notification" do
# sidekiq_assert_enqueued_with(job: Notifications::NotifiableActionWorker) do
# described_class.call(user, draft, attributes)
# end
# end
# it "delegates to the Mentions::CreateAll service to create mentions" do
# allow(Mentions::CreateAll).to receive(:call)
# described_class.call(user, draft, attributes)
# expect(Mentions::CreateAll).to have_received(:call).with(draft)
# end
# in case the article was published and unpublished before
it "updates published_at to the current time when no published_at passed" do
attributes[:published_at] = nil
past_time = 1.year.ago
draft.update_column(:published_at, past_time)
described_class.call(user, draft, attributes)
draft.reload
expect(draft.published_at).to be_within(1.minute).of(Time.current)
end
it "delegates to the Mentions::CreateAll service to create mentions" do
allow(Mentions::CreateAll).to receive(:call)
it "updates published_at to the current time when a past published_at is passed" do
attributes[:published_at] = "2020-04-05 20:20"
described_class.call(user, draft, attributes)
expect(Mentions::CreateAll).to have_received(:call).with(draft)
draft.reload
expect(draft.published_at).to be_within(1.minute).of(Time.current)
end
it "doesn't update published_at when a future published_at is passed" do
attributes[:published_at] = 1.day.from_now
described_class.call(user, draft, attributes)
draft.reload
expect(draft.published_at).to be_within(1.second).of(attributes[:published_at])
end
it "doesn't update published_at when a past published_at is passed and an article was exported" do
past = 10.days.ago
draft.update_columns(published_at: past, published_from_feed: true)
attributes[:published_at] = past
described_class.call(user, draft, attributes)
expect(draft.published_at).to be_within(1.second).of(past)
end
end
context "when an article is being updated and has already been published" do
it "doesn't enqueue a job to send a notification" do
# it "doesn't enqueue a job to send a notification" do
# attributes[:published] = true
# sidekiq_assert_not_enqueued_with(job: Notifications::NotifiableActionWorker) do
# described_class.call(user, article, attributes)
# end
# end
it "doesn't update published_at" do
attributes[:published] = true
sidekiq_assert_not_enqueued_with(job: Notifications::NotifiableActionWorker) do
described_class.call(user, article, attributes)
end
past_time = 1.year.ago
article.update_column(:published_at, past_time)
described_class.call(user, article, attributes)
article.reload
expect(article.published_at).to be_within(1.second).of(past_time)
end
it "delegates to the Mentions::CreateAll service to create mentions" do
@ -83,6 +124,14 @@ RSpec.describe Articles::Updater, type: :service do
context "when an article is unpublished" do
before { attributes[:published] = false }
it "doesn't update published_at" do
published_at = 1.day.ago
article.update_column(:published_at, published_at)
described_class.call(user, article, attributes)
article.reload
expect(article.published_at).to be_within(1.second).of(published_at)
end
it "doesn't send any notifications" do
sidekiq_assert_not_enqueued_with(job: Notifications::NotifiableActionWorker) do
described_class.call(user, article, attributes)
@ -111,6 +160,15 @@ RSpec.describe Articles::Updater, type: :service do
end
end
context "when an article is updated and remains unpublished" do
it "doesn't update published_at" do
attributes[:published] = false
described_class.call(user, draft, attributes)
article.reload
expect(draft.published_at).to be_nil
end
end
context "when an article is unpublished and contains comments" do
let!(:comment) { create(:comment, user_id: user.id, commentable: article) }
let(:notification) do

View file

@ -5,13 +5,13 @@ RSpec.describe Badges::AwardStreak, type: :service do
before do
create(:badge, title: "4 Week Streak", slug: "4-week-streak")
create(:article, user: user, published: true, published_at: 26.days.ago)
create(:article, user: user, published: true, published_at: 19.days.ago)
create(:article, user: user, published: true, published_at: 5.days.ago)
create(:article, :past, user: user, published: true, past_published_at: 26.days.ago)
create(:article, :past, user: user, published: true, past_published_at: 19.days.ago)
create(:article, :past, user: user, published: true, past_published_at: 5.days.ago)
end
it "awards badge to users with four straight weeks of articles" do
create(:article, user: user, published: true, published_at: 12.days.ago)
create(:article, :past, user: user, published: true, past_published_at: 12.days.ago)
expect do
described_class.call(weeks: 4)
end.to change { user.reload.badges.size }.by(1)

View file

@ -125,7 +125,7 @@ RSpec.describe Search::Article, type: :service do
it "supports sorting by published_at in ascending and descending order with a search term", :aggregate_failures do
article1 = create(:article, tags: "ruby")
article2 = create(:article, tags: "ruby", published_at: 1.week.ago)
article2 = create(:article, :past, tags: "ruby", past_published_at: 1.week.ago)
results = described_class.search_documents(term: "ruby", sort_by: :published_at, sort_direction: :asc)
expect(results.pluck(:id)).to eq([article2.id, article1.id])
@ -137,7 +137,7 @@ RSpec.describe Search::Article, type: :service do
it "supports sorting by published_at in ascending and descending order without a search term",
:aggregate_failures do
article1 = create(:article)
article2 = create(:article, published_at: 1.week.ago)
article2 = create(:article, :past, past_published_at: 1.week.ago)
results = described_class.search_documents(sort_by: :published_at, sort_direction: :asc)
expect(results.pluck(:id)).to eq([article2.id, article1.id])
@ -167,4 +167,5 @@ RSpec.describe Search::Article, type: :service do
end
end
end
# rubocop:enable Rails/PluckId

View file

@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Articles::Feeds::Basic, type: :system, js: true do
let(:user) { create(:user) }
let(:hot_story) do
create(:article, hotness_score: 1000, score: 1000, published_at: 3.hours.ago)
create(:article, :past, hotness_score: 1000, score: 1000, past_published_at: 3.hours.ago)
end
before do

View file

@ -139,6 +139,31 @@ RSpec.describe "Views an article", type: :system do
end
end
describe "when an article is scheduled" do
let(:scheduled_article) { create(:article, user: user, published: true, published_at: Date.tomorrow) }
let(:scheduled_article_path) { scheduled_article.path + query_params }
let(:query_params) { "?preview=#{scheduled_article.password}" }
it "shows the article edit link for the author", js: true do
visit scheduled_article_path
edit_link = find("a#author-click-to-edit")
expect(edit_link.matches_style?(display: "inline-block")).to be true
end
it "doesn't show an article edit link for the non-authorized user" do
sign_out user
sign_in create(:user)
visit scheduled_article_path
expect(page.body).to include('display: none;">Click to edit</a>')
end
it "doesn't show an article edit link when the user is not logged in" do
sign_out user
visit scheduled_article_path
expect(page.body).not_to include("Click to edit")
end
end
describe "when an article is not published" do
let(:article) { create(:article, user: article_user, published: false) }
let(:article_path) { article.path + query_params }

View file

@ -6,10 +6,12 @@ RSpec.describe "User visits articles by tag", type: :system do
let!(:func_tag) { create(:tag, name: "functional") }
let(:author) { create(:user, profile_image: nil) }
let!(:article) { create(:article, tags: "javascript, IoT", user: author, published_at: 2.days.ago, score: 5) }
let!(:article) do
create(:article, :past, past_published_at: 2.days.ago, tags: "javascript, IoT", user: author, score: 5)
end
let!(:article2) { create(:article, tags: "functional", user: author, published_at: Time.current, score: 5) }
let!(:article3) do
create(:article, tags: "functional, javascript", user: author, published_at: 2.weeks.ago, score: 5)
create(:article, :past, past_published_at: 2.weeks.ago, tags: "functional, javascript", user: author, score: 5)
end
context "when user hasn't logged in" do

View file

@ -3,10 +3,10 @@ require "rails_helper"
RSpec.describe "User visits articles by timeframe", type: :system do
let(:author) { create(:user) }
let!(:article) { create(:article, user: author, published_at: Time.current) }
let!(:days_old_article) { create(:article, user: author, published_at: 2.days.ago) }
let!(:weeks_old_article) { create(:article, user: author, published_at: 2.weeks.ago) }
let!(:months_old_article) { create(:article, user: author, published_at: 2.months.ago) }
let!(:years_old_article) { create(:article, user: author, published_at: 2.years.ago) }
let!(:days_old_article) { create(:article, :past, past_published_at: 2.days.ago, user: author) }
let!(:weeks_old_article) { create(:article, :past, past_published_at: 2.weeks.ago, user: author) }
let!(:months_old_article) { create(:article, :past, past_published_at: 2.months.ago, user: author) }
let!(:years_old_article) { create(:article, :past, past_published_at: 2.years.ago, user: author) }
def shows_correct_articles_count(count)
expect(page).to have_selector(".crayons-story", visible: :visible, count: count)

View file

@ -0,0 +1,104 @@
require "rails_helper"
RSpec.describe Articles::PublishWorker, type: :worker do
include_examples "#enqueues_on_correct_queue", "medium_priority"
describe "#perform" do
let(:worker) { subject }
let!(:article) { create(:article, published: true) }
it "calls Slack::Messengers::ArticlePublished to send slack notifications" do
allow(Slack::Messengers::ArticlePublished).to receive(:call)
worker.perform
expect(Slack::Messengers::ArticlePublished).to have_received(:call).with(article: article)
end
it "sends notifications to mentioned users and followers" do
allow(Notification).to receive(:send_to_mentioned_users_and_followers)
worker.perform
expect(Notification).to have_received(:send_to_mentioned_users_and_followers).with(article)
end
it "schedules Notifications::NotifiableActionWorker" do
args = [article.id, "Article", "Published"]
sidekiq_assert_enqueued_with(job: Notifications::NotifiableActionWorker, args: args) do
worker.perform
end
end
it "doesn't send notifications for an old article" do
old_article = create(:article, :past, published: true, past_published_at: 1.year.ago)
allow(Notification).to receive(:send_to_mentioned_users_and_followers)
worker.perform
expect(Notification).not_to have_received(:send_to_mentioned_users_and_followers).with(old_article)
end
it "doesn't send notifications for a scheduled article" do
scheduled_article = create(:article, published: true, published_at: 1.day.from_now)
allow(Notification).to receive(:send_to_mentioned_users_and_followers)
worker.perform
expect(Notification).not_to have_received(:send_to_mentioned_users_and_followers).with(scheduled_article)
end
context "with 2 articles" do
let!(:article2) { create(:article, published: true) }
it "schedules Notifications::NotifiableActionWorker twice for 2 articles" do
sidekiq_assert_enqueued_jobs(2, only: Notifications::NotifiableActionWorker) do
worker.perform
end
end
it "sends notifications to mentioned users and followers for 2 articles" do
allow(Notification).to receive(:send_to_mentioned_users_and_followers)
worker.perform
expect(Notification).to have_received(:send_to_mentioned_users_and_followers).with(article2)
end
end
describe "creating notifications" do
let!(:user2) { create(:user) }
let(:article2) { create(:article, published: true, user: user2) }
before do
user2.follow(article.user)
end
it "creates a notification eventually" do
expect do
sidekiq_perform_enqueued_jobs(only: Notifications::NotifiableActionWorker) do
worker.perform
end
end.to change(Notification, :count).by(1)
end
it "creates a context notification as well" do
expect do
sidekiq_perform_enqueued_jobs(only: Notifications::NotifiableActionWorker) do
worker.perform
end
end.to change(ContextNotification, :count).by(1)
end
it "creates a notification for each article" do
article2
article.user.follow(article2.user)
expect do
sidekiq_perform_enqueued_jobs(only: Notifications::NotifiableActionWorker) do
worker.perform
end
end.to change(Notification, :count).by(2)
end
it "creates a ContextNotification for each article" do
article2
article.user.follow(article2.user)
expect do
sidekiq_perform_enqueued_jobs(only: Notifications::NotifiableActionWorker) do
worker.perform
end
end.to change(ContextNotification, :count).by(2)
end
end
end
end