Commit 95e42fe5 authored by Sean McGivern's avatar Sean McGivern

Merge branch '296133-engineering-in-product-email-campaigns-in-saas' into 'master'

In Product Email Campaigns SaaS

See merge request gitlab-org/gitlab!50679
parents 0d4d8821 83f6888d
This diff is collapsed.
# frozen_string_literal: true
module Emails
module InProductMarketing
include InProductMarketingHelper
FROM_ADDRESS = 'GitLab <team@gitlab.com>'.freeze
CUSTOM_HEADERS = {
'X-Mailgun-Track' => 'yes',
'X-Mailgun-Track-Clicks' => 'yes',
'X-Mailgun-Track-Opens' => 'yes',
'X-Mailgun-Tag' => 'marketing'
}.freeze
def in_product_marketing_email(recipient_id, group_id, track, series)
@track = track
@series = series
@group = Group.find(group_id)
email = User.find(recipient_id).notification_email_for(@group)
subject = subject_line(track, series)
mail_to(to: email, subject: subject)
end
private
def mail_to(to:, subject:)
mail(to: to, subject: subject, from: FROM_ADDRESS, reply_to: FROM_ADDRESS, **CUSTOM_HEADERS) do |format|
format.html { render layout: nil }
format.text { render layout: nil }
end
end
end
end
...@@ -21,6 +21,7 @@ class Notify < ApplicationMailer ...@@ -21,6 +21,7 @@ class Notify < ApplicationMailer
include Emails::Groups include Emails::Groups
include Emails::Reviews include Emails::Reviews
include Emails::ServiceDesk include Emails::ServiceDesk
include Emails::InProductMarketing
helper TimeboxesHelper helper TimeboxesHelper
helper MergeRequestsHelper helper MergeRequestsHelper
...@@ -32,6 +33,7 @@ class Notify < ApplicationMailer ...@@ -32,6 +33,7 @@ class Notify < ApplicationMailer
helper AvatarsHelper helper AvatarsHelper
helper GitlabRoutingHelper helper GitlabRoutingHelper
helper IssuablesHelper helper IssuablesHelper
helper InProductMarketingHelper
def test_email(recipient_email, subject, body) def test_email(recipient_email, subject, body)
mail(to: recipient_email, mail(to: recipient_email,
......
...@@ -10,6 +10,10 @@ class Experiment < ApplicationRecord ...@@ -10,6 +10,10 @@ class Experiment < ApplicationRecord
find_or_create_by!(name: name).record_user_and_group(user, group_type, context) find_or_create_by!(name: name).record_user_and_group(user, group_type, context)
end end
def self.add_group(name, variant:, group:)
find_or_create_by!(name: name).record_group_and_variant!(group, variant)
end
def self.record_conversion_event(name, user) def self.record_conversion_event(name, user)
find_or_create_by!(name: name).record_conversion_event_for_user(user) find_or_create_by!(name: name).record_conversion_event_for_user(user)
end end
...@@ -24,4 +28,8 @@ class Experiment < ApplicationRecord ...@@ -24,4 +28,8 @@ class Experiment < ApplicationRecord
def record_conversion_event_for_user(user) def record_conversion_event_for_user(user)
experiment_users.find_by(user: user, converted_at: nil)&.touch(:converted_at) experiment_users.find_by(user: user, converted_at: nil)&.touch(:converted_at)
end end
def record_group_and_variant!(group, variant)
experiment_subjects.find_or_initialize_by(group: group).update!(variant: variant)
end
end end
...@@ -22,6 +22,24 @@ class OnboardingProgress < ApplicationRecord ...@@ -22,6 +22,24 @@ class OnboardingProgress < ApplicationRecord
:repository_mirrored :repository_mirrored
].freeze ].freeze
scope :incomplete_actions, -> (actions) do
Array.wrap(actions).inject(self) { |scope, action| scope.where(column_name(action) => nil) }
end
scope :completed_actions, -> (actions) do
Array.wrap(actions).inject(self) { |scope, action| scope.where.not(column_name(action) => nil) }
end
scope :completed_actions_with_latest_in_range, -> (actions, range) do
actions = Array(actions)
if actions.size == 1
where(column_name(actions[0]) => range)
else
action_columns = actions.map { |action| arel_table[column_name(action)] }
completed_actions(actions).where(Arel::Nodes::NamedFunction.new('GREATEST', action_columns).between(range))
end
end
class << self class << self
def onboard(namespace) def onboard(namespace)
return unless root_namespace?(namespace) return unless root_namespace?(namespace)
...@@ -44,12 +62,12 @@ class OnboardingProgress < ApplicationRecord ...@@ -44,12 +62,12 @@ class OnboardingProgress < ApplicationRecord
where(namespace: namespace).where.not(action_column => nil).exists? where(namespace: namespace).where.not(action_column => nil).exists?
end end
private
def column_name(action) def column_name(action)
:"#{action}_at" :"#{action}_at"
end end
private
def root_namespace?(namespace) def root_namespace?(namespace)
namespace && namespace.root? namespace && namespace.root?
end end
......
# frozen_string_literal: true
module Namespaces
class InProductMarketingEmailsService
include Gitlab::Experimentation::GroupTypes
INTERVAL_DAYS = [1, 5, 10].freeze
TRACKS = {
create: :git_write,
verify: :pipeline_created,
trial: :trial_started,
team: :user_added
}.freeze
def self.send_for_all_tracks_and_intervals
TRACKS.each_key do |track|
INTERVAL_DAYS.each do |interval|
new(track, interval).execute
end
end
end
def initialize(track, interval)
@track = track
@interval = interval
@sent_email_user_ids = []
end
def execute
groups_for_track.each_batch do |groups|
groups.each do |group|
send_email_for_group(group)
end
end
end
private
attr_reader :track, :interval, :sent_email_user_ids
def send_email_for_group(group)
experiment_enabled_for_group = experiment_enabled_for_group?(group)
experiment_add_group(group, experiment_enabled_for_group)
return unless experiment_enabled_for_group
users_for_group(group).each do |user|
send_email(user, group) if can_perform_action?(user, group)
end
end
def experiment_enabled_for_group?(group)
Gitlab::Experimentation.in_experiment_group?(:in_product_marketing_emails, subject: group)
end
def experiment_add_group(group, experiment_enabled_for_group)
variant = experiment_enabled_for_group ? GROUP_EXPERIMENTAL : GROUP_CONTROL
Experiment.add_group(:in_product_marketing_emails, variant: variant, group: group)
end
# rubocop: disable CodeReuse/ActiveRecord
def groups_for_track
onboarding_progress_scope = OnboardingProgress
.completed_actions_with_latest_in_range(completed_actions, range)
.incomplete_actions(incomplete_action)
Group.joins(:onboarding_progress).merge(onboarding_progress_scope)
end
def users_for_group(group)
group.users.where(email_opted_in: true)
.where.not(id: sent_email_user_ids)
end
# rubocop: enable CodeReuse/ActiveRecord
def can_perform_action?(user, group)
case track
when :create
user.can?(:create_projects, group)
when :verify
user.can?(:create_projects, group)
when :trial
user.can?(:start_trial, group)
when :team
user.can?(:admin_group_member, group)
else
raise NotImplementedError, "No ability defined for track #{track}"
end
end
def send_email(user, group)
NotificationService.new.in_product_marketing(user.id, group.id, track, series)
sent_email_user_ids << user.id
end
def completed_actions
index = TRACKS.keys.index(track)
index == 0 ? [:created] : TRACKS.values[0..index - 1]
end
def range
(interval + 1).days.ago.beginning_of_day..(interval + 1).days.ago.end_of_day
end
def incomplete_action
TRACKS[track]
end
def series
INTERVAL_DAYS.index(interval)
end
end
end
...@@ -664,6 +664,10 @@ class NotificationService ...@@ -664,6 +664,10 @@ class NotificationService
end end
end end
def in_product_marketing(user_id, group_id, track, series)
mailer.in_product_marketing_email(user_id, group_id, track, series).deliver_later
end
protected protected
def new_resource_email(target, method) def new_resource_email(target, method)
......
!!!
%html{ lang: "en" }
%head
%meta{ content: "text/html; charset=utf-8", "http-equiv" => "Content-Type" }
%meta{ content: "width=device-width, initial-scale=1", name: "viewport" }
%link{ href: "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600", rel: "stylesheet", type: "text/css" }
%title= message.subject
:css
/* CLIENT-SPECIFIC STYLES */
body,
table,
td,
a {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* RESET STYLES */
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
table {
border-collapse: collapse !important;
}
body {
height: 100% !important;
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
background-color: #ffffff;
color: #424242;
}
a {
color: #6b4fbb;
text-decoration: underline;
}
.cta_link a {
font-size: 24px;
font-family: 'Source Sans Pro', helvetica, arial, sans-serif;
color: #ffffff;
text-decoration: none;
border-radius: 5px;
-webkit-border-radius: 5px;
background-color: #6e49cb;
border-top: 15px solid #6e49cb;
border-bottom: 15px solid #6e49cb;
border-right: 40px solid #6e49cb;
border-left: 40px solid #6e49cb;
display: inline-block;
}
.footernav {
display: inline !important;
}
.footernav a {
color: #6e49cb;
}
.address {
margin: 0;
font-size: 16px;
line-height: 26px;
}
:css
/* iOS BLUE LINKS */
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/[if gte mso 9]
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
/[if (mso)|(mso 16)]
<style type="text/css">
body, table, td, a, span { font-family: Arial, Helvetica, sans-serif !important; }
</style>
:css
@media only screen and (max-width: 595px) {
.wrapper {
width: 100% !important;
margin: 0 auto !important;
padding: 0 !important;
}
p,
li {
font-size: 18px !important;
line-height: 26px !important;
}
.stack {
width: 100% !important;
}
.stack-mobile-padding {
width: 100% !important;
margin-top: 20px !important;
}
.callout {
padding-bottom: 20px !important;
}
.redbutton {
text-align: center;
}
.stack33 {
display: block !important;
width: 100% !important;
max-width: 100% !important;
direction: ltr !important;
text-align: center !important;
}
}
@media only screen and (max-width: 480px) {
u~div {
width: 100vw !important;
}
div>u~div {
width: 100% !important;
}
}
%body#body{ width: "100%" }
%table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" }
%tr
%td{ align: "center", style: "padding: 0px;" }
%table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "600" }
%tr
%td{ style: "padding: 0px;" }
#main-story.mktEditable{ mktoname: "main-story" }
%table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" }
%tr
%td{ align: "left", style: "padding: 0px;" }
= about_link('mailers/in_product_marketing', 'gitlab-logo-gray-rgb.png', 200)
%tr
%td{ "aria-hidden" => "true", height: "30", style: "font-size: 0; line-height: 0;" }
%tr
%td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" }
= in_product_marketing_logo(@track, @series)
%h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" }
= in_product_marketing_title(@track, @series)
%h2{ style: "font-size: 28px; line-height: 34px; color: #000000; padding: 0; font-weight: 400;" }
= in_product_marketing_subtitle(@track, @series)
%tr
%td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
%p{ style: "margin: 0 0 20px 0;" }
= in_product_marketing_body_line1(@track, @series, format: :html).html_safe
%p{ style: "margin: 0 0 20px 0;" }
= in_product_marketing_body_line2(@track, @series, format: :html).html_safe
%tr
%td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
.cta_link= cta_link(@track, @series, @group, format: :html)
%tr{ style: "background-color: #ffffff;" }
%td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" }
%p
= in_product_marketing_progress(@track, @series)
%tr{ style: "background-color: #ffffff;" }
%td{ align: "center", style: "padding:50px 20px 0 20px;" }
= about_link('', 'gitlab_logo.png', 80)
%tr{ style: "background-color: #ffffff;" }
%td{ align: "center", style: "padding:0px ;" }
%tr{ style: "background-color: #ffffff;" }
%td{ align: "center", style: "padding:0px 10px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; " }
%span.footernav{ style: "color: #6e49cb; font-size: 16px; line-height: 26px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
= footer_links(format: :html).join('&nbsp;' * 3 + '|' + '&nbsp;' * 4).html_safe
%tr{ style: "background-color:#ffffff;" }
%td{ align: "center", style: "padding: 40px 30px 20px 30px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
.address= address(format: :html)
%tr{ style: "background-color: #ffffff;" }
%td{ align: "left", style: "padding:20px 30px 20px 30px;" }
%span.footernav{ style: "color: #6e49cb; font-size: 14px; line-height: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#424242;" }
= unsubscribe(format: :html).html_safe
<%= in_product_marketing_tagline(@track, @series) %>
<%= in_product_marketing_title(@track, @series) %>
<%= in_product_marketing_subtitle(@track, @series) %>
<%= in_product_marketing_body_line1(@track, @series) %>
<%= in_product_marketing_body_line2(@track, @series) %>
<%= cta_link(@track, @series, @group) %>
<%= footer_links %>
<%= address %>
<%= unsubscribe %>
...@@ -251,6 +251,14 @@ ...@@ -251,6 +251,14 @@
:weight: 1 :weight: 1
:idempotent: true :idempotent: true
:tags: [] :tags: []
- :name: cronjob:namespaces_in_product_marketing_emails
:feature_category: :subgroups
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent:
:tags: []
- :name: cronjob:namespaces_prune_aggregation_schedules - :name: cronjob:namespaces_prune_aggregation_schedules
:feature_category: :source_code_management :feature_category: :source_code_management
:has_external_dependencies: :has_external_dependencies:
......
# frozen_string_literal: true
module Namespaces
class InProductMarketingEmailsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :subgroups
urgency :low
def perform
return unless Gitlab::Experimentation.active?(:in_product_marketing_emails)
Namespaces::InProductMarketingEmailsService.send_for_all_tracks_and_intervals
end
end
end
---
title: Add indexes for onboarding progress table
merge_request: 50679
author:
type: performance
---
name: in_product_marketing_emails_experiment_percentage
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50679
rollout_issue_url: https://gitlab.com/gitlab-org/growth/team-tasks/-/issues/303
milestone: "13.9"
type: experiment
group: group::activation
default_enabled: false
...@@ -544,6 +544,9 @@ Settings.cron_jobs['schedule_merge_request_cleanup_refs_worker']['job_class'] = ...@@ -544,6 +544,9 @@ Settings.cron_jobs['schedule_merge_request_cleanup_refs_worker']['job_class'] =
Settings.cron_jobs['manage_evidence_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['manage_evidence_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['manage_evidence_worker']['cron'] ||= '0 * * * *' Settings.cron_jobs['manage_evidence_worker']['cron'] ||= '0 * * * *'
Settings.cron_jobs['manage_evidence_worker']['job_class'] = 'Releases::ManageEvidenceWorker' Settings.cron_jobs['manage_evidence_worker']['job_class'] = 'Releases::ManageEvidenceWorker'
Settings.cron_jobs['namespaces_in_product_marketing_emails_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['namespaces_in_product_marketing_emails_worker']['cron'] ||= '0 9 * * *'
Settings.cron_jobs['namespaces_in_product_marketing_emails_worker']['job_class'] = 'Namespaces::InProductMarketingEmailsWorker'
Gitlab.ee do Gitlab.ee do
Settings.cron_jobs['analytics_devops_adoption_create_all_snapshots_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['analytics_devops_adoption_create_all_snapshots_worker'] ||= Settingslogic.new({})
......
# frozen_string_literal: true
class AddIndexesToOnboardingProgresses < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
CREATE_TRACK_INDEX_NAME = 'index_onboarding_progresses_for_create_track'
VERIFY_TRACK_INDEX_NAME = 'index_onboarding_progresses_for_verify_track'
TRIAL_TRACK_INDEX_NAME = 'index_onboarding_progresses_for_trial_track'
TEAM_TRACK_INDEX_NAME = 'index_onboarding_progresses_for_team_track'
disable_ddl_transaction!
def up
add_concurrent_index :onboarding_progresses, :created_at, where: 'git_write_at IS NULL', name: CREATE_TRACK_INDEX_NAME
add_concurrent_index :onboarding_progresses, :git_write_at, where: 'git_write_at IS NOT NULL AND pipeline_created_at IS NULL', name: VERIFY_TRACK_INDEX_NAME
add_concurrent_index :onboarding_progresses, 'GREATEST(git_write_at, pipeline_created_at)', where: 'git_write_at IS NOT NULL AND pipeline_created_at IS NOT NULL AND trial_started_at IS NULL', name: TRIAL_TRACK_INDEX_NAME
add_concurrent_index :onboarding_progresses, 'GREATEST(git_write_at, pipeline_created_at, trial_started_at)', where: 'git_write_at IS NOT NULL AND pipeline_created_at IS NOT NULL AND trial_started_at IS NOT NULL AND user_added_at IS NULL', name: TEAM_TRACK_INDEX_NAME
end
def down
remove_concurrent_index_by_name :onboarding_progresses, CREATE_TRACK_INDEX_NAME
remove_concurrent_index_by_name :onboarding_progresses, VERIFY_TRACK_INDEX_NAME
remove_concurrent_index_by_name :onboarding_progresses, TRIAL_TRACK_INDEX_NAME
remove_concurrent_index_by_name :onboarding_progresses, TEAM_TRACK_INDEX_NAME
end
end
7ef5cb1f167c133c67fc98c0abe929516ec700179747d3353d19cf8219ebd0ef
\ No newline at end of file
...@@ -22511,6 +22511,14 @@ CREATE INDEX index_on_users_lower_username ON users USING btree (lower((username ...@@ -22511,6 +22511,14 @@ CREATE INDEX index_on_users_lower_username ON users USING btree (lower((username
CREATE INDEX index_on_users_name_lower ON users USING btree (lower((name)::text)); CREATE INDEX index_on_users_name_lower ON users USING btree (lower((name)::text));
CREATE INDEX index_onboarding_progresses_for_create_track ON onboarding_progresses USING btree (created_at) WHERE (git_write_at IS NULL);
CREATE INDEX index_onboarding_progresses_for_team_track ON onboarding_progresses USING btree (GREATEST(git_write_at, pipeline_created_at, trial_started_at)) WHERE ((git_write_at IS NOT NULL) AND (pipeline_created_at IS NOT NULL) AND (trial_started_at IS NOT NULL) AND (user_added_at IS NULL));
CREATE INDEX index_onboarding_progresses_for_trial_track ON onboarding_progresses USING btree (GREATEST(git_write_at, pipeline_created_at)) WHERE ((git_write_at IS NOT NULL) AND (pipeline_created_at IS NOT NULL) AND (trial_started_at IS NULL));
CREATE INDEX index_onboarding_progresses_for_verify_track ON onboarding_progresses USING btree (git_write_at) WHERE ((git_write_at IS NOT NULL) AND (pipeline_created_at IS NULL));
CREATE UNIQUE INDEX index_onboarding_progresses_on_namespace_id ON onboarding_progresses USING btree (namespace_id); CREATE UNIQUE INDEX index_onboarding_progresses_on_namespace_id ON onboarding_progresses USING btree (namespace_id);
CREATE INDEX index_open_project_tracker_data_on_service_id ON open_project_tracker_data USING btree (service_id); CREATE INDEX index_open_project_tracker_data_on_service_id ON open_project_tracker_data USING btree (service_id);
......
...@@ -117,6 +117,8 @@ module EE ...@@ -117,6 +117,8 @@ module EE
condition(:over_storage_limit, scope: :subject) { @subject.over_storage_limit? } condition(:over_storage_limit, scope: :subject) { @subject.over_storage_limit? }
condition(:eligible_for_trial, scope: :subject) { @subject.eligible_for_trial? }
rule { public_group | logged_in_viewable }.policy do rule { public_group | logged_in_viewable }.policy do
enable :read_wiki enable :read_wiki
enable :download_wiki_code enable :download_wiki_code
...@@ -310,6 +312,8 @@ module EE ...@@ -310,6 +312,8 @@ module EE
rule { admin & is_gitlab_com }.enable :update_subscription_limit rule { admin & is_gitlab_com }.enable :update_subscription_limit
rule { maintainer & eligible_for_trial }.enable :start_trial
rule { over_storage_limit }.policy do rule { over_storage_limit }.policy do
prevent :create_projects prevent :create_projects
prevent :create_epic prevent :create_epic
......
...@@ -1395,5 +1395,36 @@ RSpec.describe GroupPolicy do ...@@ -1395,5 +1395,36 @@ RSpec.describe GroupPolicy do
it { is_expected.to(allowed ? be_allowed(policy) : be_disallowed(policy)) } it { is_expected.to(allowed ? be_allowed(policy) : be_disallowed(policy)) }
end end
end end
describe ':start_trial' do
using RSpec::Parameterized::TableSyntax
let(:policy) { :start_trial }
where(:role, :eligible_for_trial, :allowed) do
:guest | true | false
:guest | false | false
:reporter | true | false
:reporter | false | false
:developer | true | false
:developer | false | false
:maintainer | true | true
:maintainer | false | false
:owner | true | true
:owner | false | false
:admin | true | true
:admin | false | false
end
with_them do
let(:current_user) { public_send(role) }
before do
allow(group).to receive(:eligible_for_trial?).and_return(eligible_for_trial)
end
it { is_expected.to(allowed ? be_allowed(policy) : be_disallowed(policy)) }
end
end
end end
end end
...@@ -109,6 +109,9 @@ module Gitlab ...@@ -109,6 +109,9 @@ module Gitlab
}, },
trial_onboarding_issues: { trial_onboarding_issues: {
tracking_category: 'Growth::Conversion::Experiment::TrialOnboardingIssues' tracking_category: 'Growth::Conversion::Experiment::TrialOnboardingIssues'
},
in_product_marketing_emails: {
tracking_category: 'Growth::Activation::Experiment::InProductMarketingEmails'
} }
}.freeze }.freeze
......
This diff is collapsed.
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Experiment do RSpec.describe Experiment do
include AfterNextHelpers
subject { build(:experiment) } subject { build(:experiment) }
describe 'associations' do describe 'associations' do
...@@ -67,6 +69,33 @@ RSpec.describe Experiment do ...@@ -67,6 +69,33 @@ RSpec.describe Experiment do
end end
end end
describe '.add_group' do
let_it_be(:experiment_name) { :experiment_key }
let_it_be(:variant) { :control }
let_it_be(:group) { build(:group) }
subject(:add_group) { described_class.add_group(experiment_name, variant: variant, group: group) }
context 'when an experiment with the provided name does not exist' do
it 'creates a new experiment record' do
allow_next(described_class, name: :experiment_key)
.to receive(:record_group_and_variant!).with(group, variant)
expect { add_group }.to change(described_class, :count).by(1)
end
end
context 'when an experiment with the provided name already exists' do
before do
create(:experiment, name: experiment_name)
end
it 'does not create a new experiment record' do
expect { add_group }.not_to change(described_class, :count)
end
end
end
describe '.record_conversion_event' do describe '.record_conversion_event' do
let_it_be(:user) { build(:user) } let_it_be(:user) { build(:user) }
...@@ -136,6 +165,34 @@ RSpec.describe Experiment do ...@@ -136,6 +165,34 @@ RSpec.describe Experiment do
end end
end end
describe '#record_group_and_variant!' do
let_it_be(:group) { create(:group) }
let_it_be(:variant) { :control }
let_it_be(:experiment) { create(:experiment) }
subject(:record_group_and_variant!) { experiment.record_group_and_variant!(group, variant) }
context 'when no existing experiment_subject record exists for the given group' do
it 'creates an experiment_subject record' do
expect_next(ExperimentSubject).to receive(:update!).with(variant: variant).and_call_original
expect { record_group_and_variant! }.to change(ExperimentSubject, :count).by(1)
end
end
context 'when an existing experiment_subject exists for the given group' do
context 'but it belonged to a different variant' do
let!(:experiment_subject) do
create(:experiment_subject, experiment: experiment, group: group, user: nil, variant: :experimental)
end
it 'updates the variant value' do
expect { record_group_and_variant! }.to change { experiment_subject.reload.variant }.to('control')
end
end
end
end
describe '#record_user_and_group' do describe '#record_user_and_group' do
let_it_be(:experiment) { create(:experiment) } let_it_be(:experiment) { create(:experiment) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
......
...@@ -29,6 +29,67 @@ RSpec.describe OnboardingProgress do ...@@ -29,6 +29,67 @@ RSpec.describe OnboardingProgress do
end end
end end
describe 'scopes' do
describe '.incomplete_actions' do
subject { described_class.incomplete_actions(actions) }
let!(:no_actions_completed) { create(:onboarding_progress) }
let!(:one_action_completed_one_action_incompleted) { create(:onboarding_progress, "#{action}_at" => Time.current) }
context 'when given one action' do
let(:actions) { action }
it { is_expected.to eq [no_actions_completed] }
end
context 'when given an array of actions' do
let(:actions) { [action, :git_write] }
it { is_expected.to eq [no_actions_completed] }
end
end
describe '.completed_actions' do
subject { described_class.completed_actions(actions) }
let!(:one_action_completed_one_action_incompleted) { create(:onboarding_progress, "#{action}_at" => Time.current) }
let!(:both_actions_completed) { create(:onboarding_progress, "#{action}_at" => Time.current, git_write_at: Time.current) }
context 'when given one action' do
let(:actions) { action }
it { is_expected.to eq [one_action_completed_one_action_incompleted, both_actions_completed] }
end
context 'when given an array of actions' do
let(:actions) { [action, :git_write] }
it { is_expected.to eq [both_actions_completed] }
end
end
describe '.completed_actions_with_latest_in_range' do
subject { described_class.completed_actions_with_latest_in_range(actions, 1.day.ago.beginning_of_day..1.day.ago.end_of_day) }
let!(:one_action_completed_in_range_one_action_incompleted) { create(:onboarding_progress, "#{action}_at" => 1.day.ago.middle_of_day) }
let!(:git_write_action_completed_in_range) { create(:onboarding_progress, git_write_at: 1.day.ago.middle_of_day) }
let!(:both_actions_completed_latest_action_out_of_range) { create(:onboarding_progress, "#{action}_at" => 1.day.ago.middle_of_day, git_write_at: Time.current) }
let!(:both_actions_completed_latest_action_in_range) { create(:onboarding_progress, "#{action}_at" => 1.day.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day) }
context 'when given one action' do
let(:actions) { :git_write }
it { is_expected.to eq [git_write_action_completed_in_range] }
end
context 'when given an array of actions' do
let(:actions) { [action, :git_write] }
it { is_expected.to eq [both_actions_completed_latest_action_in_range] }
end
end
end
describe '.onboard' do describe '.onboard' do
subject(:onboard) { described_class.onboard(namespace) } subject(:onboard) { described_class.onboard(namespace) }
...@@ -104,4 +165,10 @@ RSpec.describe OnboardingProgress do ...@@ -104,4 +165,10 @@ RSpec.describe OnboardingProgress do
end end
end end
end end
describe '.column_name' do
subject { described_class.column_name(action) }
it { is_expected.to eq(:subscription_created_at) }
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do
subject(:execute_service) { described_class.new(track, interval).execute }
let(:track) { :create }
let(:interval) { 1 }
let(:previous_action_completed_at) { 2.days.ago.middle_of_day }
let(:current_action_completed_at) { nil }
let(:experiment_enabled) { true }
let(:user_can_perform_current_track_action) { true }
let(:actions_completed) { { created_at: previous_action_completed_at, git_write_at: current_action_completed_at } }
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user, email_opted_in: true) }
before do
create(:onboarding_progress, namespace: group, **actions_completed)
group.add_developer(user)
stub_experiment_for_subject(in_product_marketing_emails: experiment_enabled)
allow(Ability).to receive(:allowed?).with(user, anything, anything).and_return(user_can_perform_current_track_action)
allow(Notify).to receive(:in_product_marketing_email).and_return(double(deliver_later: nil))
end
RSpec::Matchers.define :send_in_product_marketing_email do |*args|
match do
expect(Notify).to have_received(:in_product_marketing_email).with(*args).once
end
match_when_negated do
expect(Notify).not_to have_received(:in_product_marketing_email)
end
end
context 'for each track and series with the right conditions' do
using RSpec::Parameterized::TableSyntax
where(:track, :interval, :actions_completed) do
:create | 1 | { created_at: 2.days.ago.middle_of_day }
:create | 5 | { created_at: 6.days.ago.middle_of_day }
:create | 10 | { created_at: 11.days.ago.middle_of_day }
:verify | 1 | { created_at: 2.days.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day }
:verify | 5 | { created_at: 6.days.ago.middle_of_day, git_write_at: 6.days.ago.middle_of_day }
:verify | 10 | { created_at: 11.days.ago.middle_of_day, git_write_at: 11.days.ago.middle_of_day }
:trial | 1 | { created_at: 2.days.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day, pipeline_created_at: 2.days.ago.middle_of_day }
:trial | 5 | { created_at: 6.days.ago.middle_of_day, git_write_at: 6.days.ago.middle_of_day, pipeline_created_at: 6.days.ago.middle_of_day }
:trial | 10 | { created_at: 11.days.ago.middle_of_day, git_write_at: 11.days.ago.middle_of_day, pipeline_created_at: 11.days.ago.middle_of_day }
:team | 1 | { created_at: 2.days.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day, pipeline_created_at: 2.days.ago.middle_of_day, trial_started_at: 2.days.ago.middle_of_day }
:team | 5 | { created_at: 6.days.ago.middle_of_day, git_write_at: 6.days.ago.middle_of_day, pipeline_created_at: 6.days.ago.middle_of_day, trial_started_at: 6.days.ago.middle_of_day }
:team | 10 | { created_at: 11.days.ago.middle_of_day, git_write_at: 11.days.ago.middle_of_day, pipeline_created_at: 11.days.ago.middle_of_day, trial_started_at: 11.days.ago.middle_of_day }
end
with_them do
it { is_expected.to send_in_product_marketing_email(user.id, group.id, track, described_class::INTERVAL_DAYS.index(interval)) }
end
end
context 'when initialized with a different track' do
let(:track) { :verify }
it { is_expected.not_to send_in_product_marketing_email }
context 'when the previous track actions have been completed' do
let(:current_action_completed_at) { 2.days.ago.middle_of_day }
it { is_expected.to send_in_product_marketing_email(user.id, group.id, :verify, 0) }
end
end
context 'when initialized with a different interval' do
let(:interval) { 5 }
it { is_expected.not_to send_in_product_marketing_email }
context 'when the previous track action was completed within the intervals range' do
let(:previous_action_completed_at) { 6.days.ago.middle_of_day }
it { is_expected.to send_in_product_marketing_email(user.id, group.id, :create, 1) }
end
end
describe 'experimentation' do
context 'when the experiment is enabled' do
it 'adds the group as an experiment subject in the experimental group' do
expect(Experiment).to receive(:add_group)
.with(:in_product_marketing_emails, variant: :experimental, group: group)
execute_service
end
end
context 'when the experiment is disabled' do
let(:experiment_enabled) { false }
it 'adds the group as an experiment subject in the control group' do
expect(Experiment).to receive(:add_group)
.with(:in_product_marketing_emails, variant: :control, group: group)
execute_service
end
it { is_expected.not_to send_in_product_marketing_email }
end
end
context 'when the previous track action is not yet completed' do
let(:previous_action_completed_at) { nil }
it { is_expected.not_to send_in_product_marketing_email }
end
context 'when the previous track action is completed outside the intervals range' do
let(:previous_action_completed_at) { 3.days.ago }
it { is_expected.not_to send_in_product_marketing_email }
end
context 'when the current track action is completed' do
let(:current_action_completed_at) { Time.current }
it { is_expected.not_to send_in_product_marketing_email }
end
context "when the user cannot perform the current track's action" do
let(:user_can_perform_current_track_action) { false }
it { is_expected.not_to send_in_product_marketing_email }
end
context 'when the user has not opted into marketing emails' do
let(:user) { create(:user, email_opted_in: false) }
it { is_expected.not_to send_in_product_marketing_email }
end
context 'when the user has already received a marketing email as part of another group' do
before do
other_group = create(:group)
other_group.add_developer(user)
create(:onboarding_progress, namespace: other_group, created_at: previous_action_completed_at, git_write_at: current_action_completed_at)
end
# For any group Notify is called exactly once
it { is_expected.to send_in_product_marketing_email(user.id, anything, :create, 0) }
end
context 'when invoked with a non existing track' do
let(:track) { :foo }
before do
stub_const("#{described_class}::TRACKS", { foo: :git_write })
end
it { expect { subject }.to raise_error(NotImplementedError, 'No ability defined for track foo') }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Namespaces::InProductMarketingEmailsWorker, '#perform' do
context 'when the experiment is inactive' do
before do
stub_experiment(in_product_marketing_emails: false)
end
it 'does not execute the in product marketing emails service' do
expect(Namespaces::InProductMarketingEmailsService).not_to receive(:send_for_all_tracks_and_intervals)
subject.perform
end
end
context 'when the experiment is active' do
before do
stub_experiment(in_product_marketing_emails: true)
end
it 'calls the send_for_all_tracks_and_intervals method on the in product marketing emails service' do
expect(Namespaces::InProductMarketingEmailsService).to receive(:send_for_all_tracks_and_intervals)
subject.perform
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment