Commit iniziale di eminuxCRM: CRM Rails con pipeline, campagne email e Docker.
Include autenticazione, progetti isolati, mail marketing HTML con SMTP, test A/B e editor WYSIWYG. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
require "test_helper"
|
||||
|
||||
class AuthenticationTest < ActionDispatch::IntegrationTest
|
||||
test "requires login" do
|
||||
get root_path
|
||||
assert_redirected_to login_path
|
||||
end
|
||||
|
||||
test "login and logout" do
|
||||
login_as users(:admin)
|
||||
assert_response :redirect
|
||||
follow_redirect!
|
||||
assert_response :success
|
||||
|
||||
delete logout_path
|
||||
assert_redirected_to login_path
|
||||
end
|
||||
|
||||
test "non admin cannot create users" do
|
||||
login_as users(:marco)
|
||||
follow_redirect! if response.redirect?
|
||||
post users_path, params: {
|
||||
user: { email: "x@example.com", first_name: "X", last_name: "Y", role: "user", password: "password123", password_confirmation: "password123" }
|
||||
}
|
||||
assert_response :redirect
|
||||
end
|
||||
|
||||
test "project space is isolated by url" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
get project_root_path(project_code: "matchlivetv")
|
||||
assert_response :success
|
||||
assert_match(/MatchLiveTV/, response.body)
|
||||
|
||||
get project_root_path(project_code: "riskmeter")
|
||||
assert_response :success
|
||||
assert_match(/RiskMeter/, response.body)
|
||||
end
|
||||
|
||||
test "user cannot open disabled project" do
|
||||
login_as users(:marco)
|
||||
follow_redirect! if response.redirect?
|
||||
get project_root_path(project_code: "riskmeter")
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "disabled user cannot login" do
|
||||
admin = users(:admin)
|
||||
User.create!(
|
||||
email: "keeper@example.com",
|
||||
first_name: "Keep",
|
||||
last_name: "Admin",
|
||||
role: "admin",
|
||||
password: "password123",
|
||||
password_confirmation: "password123"
|
||||
)
|
||||
assert admin.update(active: false)
|
||||
|
||||
post login_path, params: { email: admin.email, password: "password123" }
|
||||
assert_response :unprocessable_entity
|
||||
assert_match(/Email o password non validi/i, response.body)
|
||||
end
|
||||
|
||||
test "admin can open platform settings" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
get admin_path
|
||||
assert_response :success
|
||||
assert_match(/Impostazioni piattaforma/, response.body)
|
||||
assert_match(/Utenti/, response.body)
|
||||
end
|
||||
|
||||
test "non admin cannot open platform settings" do
|
||||
login_as users(:marco)
|
||||
follow_redirect! if response.redirect?
|
||||
get admin_path
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "admin home has new project and settings actions" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
get root_path
|
||||
assert_response :success
|
||||
assert_match(/Nuovo progetto/, response.body)
|
||||
assert_match(/Impostazioni/, response.body)
|
||||
assert_no_match(/Amministrazione progetti/, response.body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
require "test_helper"
|
||||
|
||||
class MailIdentitiesControllerTest < ActionDispatch::IntegrationTest
|
||||
test "non admin cannot manage smtp accounts" do
|
||||
login_as users(:marco)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
get mail_identities_path
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "admin can create an smtp account" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
assert_difference "MailIdentity.count", 1 do
|
||||
post mail_identities_path, params: {
|
||||
mail_identity: {
|
||||
name: "MatchLiveTV",
|
||||
from_name: "MatchLiveTV",
|
||||
from_email: "hello@matchlivetv.test",
|
||||
smtp_host: "smtp.test",
|
||||
smtp_port: 587,
|
||||
smtp_username: "hello@matchlivetv.test",
|
||||
smtp_password: "super-secret",
|
||||
smtp_authentication: "plain",
|
||||
encryption: "starttls",
|
||||
verify_ssl: "1",
|
||||
active: "1"
|
||||
}
|
||||
}
|
||||
end
|
||||
assert_redirected_to mail_identities_path
|
||||
identity = MailIdentity.find_by!(from_email: "hello@matchlivetv.test")
|
||||
assert_equal "super-secret", identity.smtp_password
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
require "test_helper"
|
||||
require "base64"
|
||||
|
||||
class MailImagesControllerTest < ActionDispatch::IntegrationTest
|
||||
PNG = Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
|
||||
|
||||
test "admin can upload an image for the editor" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
upload = png_upload
|
||||
assert_difference -> { ActiveStorage::Blob.count }, 1 do
|
||||
post mail_images_path(project_code: "matchlivetv"), params: { file: upload }
|
||||
end
|
||||
assert_response :success
|
||||
json = JSON.parse(response.body)
|
||||
assert json["url"].present?
|
||||
assert_match(/active_storage/, json["url"])
|
||||
end
|
||||
|
||||
test "rejects non image files" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
file = Tempfile.new(["note", ".txt"])
|
||||
file.write("not-an-image")
|
||||
file.rewind
|
||||
|
||||
post mail_images_path(project_code: "matchlivetv"), params: {
|
||||
file: Rack::Test::UploadedFile.new(file.path, "text/plain")
|
||||
}
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def png_upload
|
||||
file = Tempfile.new(["pixel", ".png"])
|
||||
file.binmode
|
||||
file.write(PNG)
|
||||
file.rewind
|
||||
Rack::Test::UploadedFile.new(file.path, "image/png")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
require "test_helper"
|
||||
|
||||
class MailingsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@project = projects(:matchlivetv)
|
||||
@identity = create_mail_identity
|
||||
opportunities(:deal).update!(send_status: "to_send", pipeline_stage: "to_contact")
|
||||
end
|
||||
|
||||
test "lists mailings in project space" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
get mailings_path(project_code: @project.code)
|
||||
assert_response :success
|
||||
assert_match(/Email/, response.body)
|
||||
end
|
||||
|
||||
test "creates a draft and builds recipients" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
assert_difference -> { Mailing.count } => 1, -> { MailingRecipient.count } => 1 do
|
||||
post mailings_path(project_code: @project.code), params: {
|
||||
mailing: {
|
||||
name: "Lancio",
|
||||
mail_identity_id: @identity.id,
|
||||
audience: "to_send",
|
||||
subject: "Ciao {{societa}}",
|
||||
body_html: "<p>Ciao {{contatto_nome}}</p>",
|
||||
interval_seconds: 0
|
||||
}
|
||||
}
|
||||
end
|
||||
mailing = Mailing.last
|
||||
assert_redirected_to mailing_path(mailing, project_code: @project.code)
|
||||
assert_equal "pending", mailing.mailing_recipients.first.status
|
||||
end
|
||||
|
||||
test "check recipients then queue send" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
mailing = create_mailing(identity: @identity, audience: "to_send")
|
||||
mailing.rebuild_recipients!
|
||||
recipient = mailing.mailing_recipients.first
|
||||
|
||||
patch update_recipients_mailing_path(mailing, project_code: @project.code), params: { pending_ids: [recipient.id] }
|
||||
assert_redirected_to mailing_path(mailing, project_code: @project.code)
|
||||
assert_equal "pending", recipient.reload.status
|
||||
|
||||
perform_enqueued_jobs do
|
||||
post queue_mailing_path(mailing, project_code: @project.code)
|
||||
end
|
||||
|
||||
assert_equal "sent", mailing.reload.status
|
||||
assert_equal "sent", recipient.reload.status
|
||||
assert_equal "sent", opportunities(:deal).reload.send_status
|
||||
assert_equal "contacted", opportunities(:deal).pipeline_stage
|
||||
assert_equal 1, ActionMailer::Base.deliveries.size
|
||||
assert_equal "Ciao ASD Test Calcio", ActionMailer::Base.deliveries.last.subject
|
||||
end
|
||||
|
||||
test "test send goes to current user" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
mailing = create_mailing(identity: @identity, audience: "all")
|
||||
mailing.rebuild_recipients!
|
||||
|
||||
assert_emails 1 do
|
||||
post test_send_mailing_path(mailing, project_code: @project.code)
|
||||
end
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
assert_equal [users(:admin).email], mail.to
|
||||
assert_match(/\[TEST\]/, mail.subject)
|
||||
end
|
||||
|
||||
test "creates an A/B mailing and sends variant B as test" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
post mailings_path(project_code: @project.code), params: {
|
||||
mailing: {
|
||||
name: "AB lancio",
|
||||
mail_identity_id: @identity.id,
|
||||
audience: "to_send",
|
||||
ab_test: "1",
|
||||
ab_assignment: "from_record",
|
||||
subject: "Oggetto A {{societa}}",
|
||||
body_html: "<p>Versione A</p>",
|
||||
subject_b: "Oggetto B {{societa}}",
|
||||
body_html_b: "<p>Versione B</p>",
|
||||
interval_seconds: 0
|
||||
}
|
||||
}
|
||||
mailing = Mailing.last
|
||||
assert mailing.ab_test?
|
||||
assert_equal "A", mailing.mailing_recipients.first.ab_variant
|
||||
|
||||
assert_emails 1 do
|
||||
post test_send_mailing_path(mailing, project_code: @project.code, variant: "B")
|
||||
end
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
assert_match(/\[TEST B\] Oggetto B ASD Test Calcio/, mail.subject)
|
||||
body = (mail.html_part || mail).body.to_s
|
||||
assert_match(/Versione B/, body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
require "test_helper"
|
||||
|
||||
class UsersControllerTest < ActionDispatch::IntegrationTest
|
||||
test "non admin cannot list or create users" do
|
||||
login_as users(:marco)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
get users_path
|
||||
assert_redirected_to root_path
|
||||
|
||||
post users_path, params: {
|
||||
user: { email: "x@example.com", first_name: "X", last_name: "Y", role: "user", password: "password123", password_confirmation: "password123" }
|
||||
}
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "admin can create a user" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
assert_difference "User.count", 1 do
|
||||
post users_path, params: {
|
||||
user: {
|
||||
email: "nuovo@example.com",
|
||||
first_name: "Nuovo",
|
||||
last_name: "Utente",
|
||||
role: "user",
|
||||
password: "password123",
|
||||
password_confirmation: "password123"
|
||||
},
|
||||
project_ids: [ projects(:matchlivetv).id ]
|
||||
}
|
||||
end
|
||||
assert_redirected_to users_path
|
||||
user = User.find_by!(email: "nuovo@example.com")
|
||||
assert user.project_enabled?(projects(:matchlivetv))
|
||||
assert_not user.project_enabled?(projects(:riskmeter))
|
||||
end
|
||||
|
||||
test "admin can disable another admin when a second admin exists" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
other = User.create!(
|
||||
email: "emiliano@example.com",
|
||||
first_name: "Emiliano",
|
||||
last_name: "Admin",
|
||||
role: "admin",
|
||||
password: "password123",
|
||||
password_confirmation: "password123"
|
||||
)
|
||||
|
||||
patch user_path(other), params: {
|
||||
user: { first_name: other.first_name, last_name: other.last_name, email: other.email, role: "admin", active: "0" }
|
||||
}
|
||||
assert_redirected_to users_path
|
||||
assert_not other.reload.active?
|
||||
end
|
||||
|
||||
test "admin cannot disable last active admin" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
admin = users(:admin)
|
||||
|
||||
patch user_path(admin), params: {
|
||||
user: { first_name: admin.first_name, last_name: admin.last_name, email: admin.email, role: "admin", active: "0" }
|
||||
}
|
||||
assert_response :unprocessable_entity
|
||||
assert admin.reload.active?
|
||||
assert_match(/almeno un amministratore attivo/i, response.body)
|
||||
end
|
||||
|
||||
test "admin cannot delete self" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
assert_no_difference "User.count" do
|
||||
delete user_path(users(:admin))
|
||||
end
|
||||
assert_redirected_to users_path
|
||||
follow_redirect!
|
||||
assert_match(/Non puoi eliminare il tuo account/i, response.body)
|
||||
end
|
||||
|
||||
test "admin can delete another admin when a second remains" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
|
||||
other = User.create!(
|
||||
email: "other-admin@example.com",
|
||||
first_name: "Other",
|
||||
last_name: "Admin",
|
||||
role: "admin",
|
||||
password: "password123",
|
||||
password_confirmation: "password123"
|
||||
)
|
||||
|
||||
assert_difference "User.count", -1 do
|
||||
delete user_path(other)
|
||||
end
|
||||
assert_redirected_to users_path
|
||||
end
|
||||
|
||||
test "admin can delete another user and nullify activities" do
|
||||
login_as users(:admin)
|
||||
follow_redirect! if response.redirect?
|
||||
marco = users(:marco)
|
||||
activity = activities(:note)
|
||||
assert_equal marco.id, activity.user_id
|
||||
|
||||
assert_difference "User.count", -1 do
|
||||
delete user_path(marco)
|
||||
end
|
||||
assert_redirected_to users_path
|
||||
assert_nil activity.reload.user_id
|
||||
end
|
||||
end
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
note:
|
||||
activity_type: note
|
||||
subject: Prima nota
|
||||
description: Test
|
||||
happened_at: <%= 2.days.ago %>
|
||||
user: marco
|
||||
organization: acme
|
||||
contact: primary
|
||||
opportunity: deal
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
primary:
|
||||
organization: acme
|
||||
first_name: Mario
|
||||
last_name: Rossi
|
||||
role: Presidente
|
||||
email: mario@asdtest.example.it
|
||||
phone: "+39 333 0001111"
|
||||
primary_contact: true
|
||||
preferred_contact_method: email
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
deal:
|
||||
organization: acme
|
||||
project: matchlivetv
|
||||
name: Licenza Full
|
||||
pipeline_stage: interested
|
||||
estimated_value: 1990
|
||||
probability: 40
|
||||
product: Full
|
||||
assigned_user: marco
|
||||
stage_changed_at: <%= 3.days.ago %>
|
||||
first_contacted_at: <%= 10.days.ago %>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
acme_matchlivetv:
|
||||
organization: acme
|
||||
project: matchlivetv
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
acme:
|
||||
name: ASD Test Calcio
|
||||
organization_type: societa_sportiva
|
||||
sport: Calcio
|
||||
country: Italia
|
||||
region: Lombardia
|
||||
city: Milano
|
||||
status: prospect
|
||||
lead_source: outbound
|
||||
assigned_user: marco
|
||||
email: info@asdtest.example.it
|
||||
website: asdtest.example.it
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
light:
|
||||
name: Light
|
||||
code: light
|
||||
active: true
|
||||
position: 0
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
matchlivetv:
|
||||
name: MatchLiveTV
|
||||
code: matchlivetv
|
||||
description: Streaming
|
||||
active: true
|
||||
position: 0
|
||||
|
||||
riskmeter:
|
||||
name: RiskMeter
|
||||
code: riskmeter
|
||||
description: Risk
|
||||
active: true
|
||||
position: 1
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
september:
|
||||
name: Prime 10 società paganti
|
||||
metric: customers_acquired
|
||||
target_value: 10
|
||||
start_date: 2026-08-16
|
||||
end_date: 2026-09-30
|
||||
active: true
|
||||
project: matchlivetv
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
follow_up:
|
||||
title: Richiama presidente
|
||||
organization: acme
|
||||
contact: primary
|
||||
opportunity: deal
|
||||
assigned_user: marco
|
||||
due_at: <%= Time.zone.now.change(hour: 15) %>
|
||||
priority: high
|
||||
task_type: follow_up
|
||||
status: pending
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
marco_matchlivetv:
|
||||
user: marco
|
||||
project: matchlivetv
|
||||
enabled: true
|
||||
|
||||
marco_riskmeter:
|
||||
user: marco
|
||||
project: riskmeter
|
||||
enabled: false
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
admin:
|
||||
email: admin@example.com
|
||||
password_digest: <%= BCrypt::Password.create("password123") %>
|
||||
first_name: Admin
|
||||
last_name: User
|
||||
role: admin
|
||||
active: true
|
||||
|
||||
marco:
|
||||
email: marco@example.com
|
||||
password_digest: <%= BCrypt::Password.create("password123") %>
|
||||
first_name: Marco
|
||||
last_name: Test
|
||||
role: user
|
||||
active: true
|
||||
@@ -0,0 +1,17 @@
|
||||
require "test_helper"
|
||||
|
||||
class SendMailingRecipientJobTest < ActiveJob::TestCase
|
||||
test "sends pending recipients in sequence" do
|
||||
opportunities(:deal).update!(send_status: "to_send")
|
||||
mailing = create_mailing(audience: "to_send")
|
||||
mailing.rebuild_recipients!
|
||||
mailing.update!(status: "sending", queued_at: Time.current)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
SendMailingRecipientJob.perform_later(mailing.mailing_recipients.first.id)
|
||||
end
|
||||
|
||||
assert_equal "sent", mailing.reload.status
|
||||
assert_equal "sent", mailing.mailing_recipients.first.status
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
require "test_helper"
|
||||
require "base64"
|
||||
|
||||
class CampaignMailerTest < ActionMailer::TestCase
|
||||
test "sends html with merge and attachments from the identity" do
|
||||
mailing = create_mailing
|
||||
mailing.rebuild_recipients!
|
||||
mailing.files.attach(io: StringIO.new("brochure"), filename: "brochure.pdf", content_type: "application/pdf")
|
||||
recipient = mailing.mailing_recipients.first
|
||||
|
||||
email = CampaignMailer.outreach(
|
||||
recipient,
|
||||
html: recipient.rendered_html,
|
||||
subject: recipient.rendered_subject_line
|
||||
)
|
||||
|
||||
assert_emails 1 do
|
||||
email.deliver_now
|
||||
end
|
||||
|
||||
assert_equal ["hello@example.com"], email.from
|
||||
assert_equal ["mario@asdtest.example.it"], email.to
|
||||
assert_equal "Ciao ASD Test Calcio", email.subject
|
||||
body = (email.html_part || email).body.to_s
|
||||
assert_match(/Ciao Mario Rossi di ASD Test Calcio/, body)
|
||||
assert_equal 1, email.attachments.size
|
||||
assert_equal "brochure.pdf", email.attachments.first.filename
|
||||
end
|
||||
|
||||
test "inlines active storage images as cid attachments" do
|
||||
png = Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
|
||||
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(png), filename: "pixel.png", content_type: "image/png")
|
||||
src = Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
|
||||
mailing = create_mailing(body_html: %(<p>Foto</p><figure><img src="#{src}" width="240" height="120"></figure>))
|
||||
mailing.rebuild_recipients!
|
||||
recipient = mailing.mailing_recipients.first
|
||||
|
||||
email = CampaignMailer.outreach(recipient, html: recipient.rendered_html, subject: "Foto")
|
||||
email.deliver_now
|
||||
|
||||
assert email.attachments.any? { |att| att.filename.to_s.include?("pixel.png") }
|
||||
body = (email.html_part || email).body.to_s
|
||||
assert_match(/cid:/, body)
|
||||
assert_match(/width:\s*240px/, body)
|
||||
assert_no_match(/<figure/, body)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
require "test_helper"
|
||||
|
||||
class DashboardMetricsTest < ActiveSupport::TestCase
|
||||
test "computes stage counts and pipeline value" do
|
||||
metrics = Dashboard::Metrics.new
|
||||
assert metrics.stage_counts.key?("interested")
|
||||
assert_kind_of Numeric, metrics.open_pipeline_value
|
||||
assert_kind_of Array, metrics.funnel_steps
|
||||
end
|
||||
|
||||
test "goal progress" do
|
||||
goal = sales_goals(:september)
|
||||
assert_equal 0, goal.current_value
|
||||
assert_equal 0, goal.progress_percentage
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
require "test_helper"
|
||||
|
||||
class OpportunityTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@opp = opportunities(:deal)
|
||||
@user = users(:marco)
|
||||
Current.user = @user
|
||||
Current.project = projects(:matchlivetv)
|
||||
end
|
||||
|
||||
teardown do
|
||||
Current.user = nil
|
||||
Current.project = nil
|
||||
end
|
||||
|
||||
test "move to won sets won_at and activates customer" do
|
||||
@opp.move_to_stage!("won", user: @user)
|
||||
assert @opp.won?
|
||||
assert_not_nil @opp.won_at
|
||||
assert_equal "active_customer", @opp.organization.reload.status
|
||||
end
|
||||
|
||||
test "move to lost requires reason" do
|
||||
assert_raises(ActiveRecord::RecordInvalid) do
|
||||
@opp.move_to_stage!("lost", user: @user)
|
||||
end
|
||||
end
|
||||
|
||||
test "move to lost with reason" do
|
||||
@opp.move_to_stage!("lost", lost_reason: "price", user: @user)
|
||||
assert @opp.lost?
|
||||
assert_equal "price", @opp.lost_reason
|
||||
assert_not_nil @opp.lost_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
require "test_helper"
|
||||
|
||||
class OrganizationTest < ActiveSupport::TestCase
|
||||
test "requires name" do
|
||||
org = Organization.new(status: "prospect", organization_type: "azienda")
|
||||
assert_not org.valid?
|
||||
assert_includes org.errors[:name], "non può essere vuoto"
|
||||
end
|
||||
|
||||
test "has associations" do
|
||||
org = organizations(:acme)
|
||||
assert_includes org.contacts, contacts(:primary)
|
||||
assert_includes org.opportunities, opportunities(:deal)
|
||||
assert_includes org.tasks, tasks(:follow_up)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
require "test_helper"
|
||||
|
||||
class ProjectAccessTest < ActiveSupport::TestCase
|
||||
test "admin can access all projects" do
|
||||
admin = users(:admin)
|
||||
assert admin.can_access_project?(projects(:matchlivetv))
|
||||
assert admin.can_access_project?(projects(:riskmeter))
|
||||
assert_equal Project.active.count, admin.accessible_projects.count
|
||||
end
|
||||
|
||||
test "user only accesses enabled projects" do
|
||||
marco = users(:marco)
|
||||
assert marco.can_access_project?(projects(:matchlivetv))
|
||||
assert_not marco.can_access_project?(projects(:riskmeter))
|
||||
end
|
||||
|
||||
test "organization scoped to project" do
|
||||
assert_includes Organization.for_project(projects(:matchlivetv)), organizations(:acme)
|
||||
assert_not_includes Organization.for_project(projects(:riskmeter)), organizations(:acme)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
require "test_helper"
|
||||
|
||||
class TaskTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@task = tasks(:follow_up)
|
||||
@user = users(:marco)
|
||||
Current.user = @user
|
||||
end
|
||||
|
||||
teardown { Current.user = nil }
|
||||
|
||||
test "complete creates activity" do
|
||||
assert_difference -> { Activity.count }, 1 do
|
||||
assert @task.complete!(user: @user)
|
||||
end
|
||||
assert @task.completed?
|
||||
assert_not_nil @task.completed_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
require "test_helper"
|
||||
|
||||
class UserManagementTest < ActiveSupport::TestCase
|
||||
test "cannot deactivate the last active admin" do
|
||||
admin = users(:admin)
|
||||
assert admin.last_active_admin?
|
||||
assert_not admin.can_be_deactivated?
|
||||
|
||||
admin.active = false
|
||||
assert_not admin.valid?
|
||||
assert_includes admin.errors[:base].join, "almeno un amministratore attivo"
|
||||
end
|
||||
|
||||
test "cannot demote the last active admin" do
|
||||
admin = users(:admin)
|
||||
admin.role = "user"
|
||||
assert_not admin.valid?
|
||||
assert_includes admin.errors[:base].join, "almeno un amministratore attivo"
|
||||
end
|
||||
|
||||
test "can deactivate seed admin when another admin exists" do
|
||||
other = User.create!(
|
||||
email: "second-admin@example.com",
|
||||
first_name: "Second",
|
||||
last_name: "Admin",
|
||||
role: "admin",
|
||||
password: "password123",
|
||||
password_confirmation: "password123",
|
||||
active: true
|
||||
)
|
||||
admin = users(:admin)
|
||||
|
||||
assert_not admin.last_active_admin?
|
||||
assert admin.can_be_deactivated?
|
||||
assert admin.update(active: false)
|
||||
assert_not admin.reload.active?
|
||||
assert other.reload.active?
|
||||
end
|
||||
|
||||
test "cannot destroy the last active admin" do
|
||||
admin = users(:admin)
|
||||
assert_not admin.destroy
|
||||
assert admin.errors[:base].join.include?("unico amministratore attivo") || User.exists?(admin.id)
|
||||
end
|
||||
|
||||
test "can destroy a regular user" do
|
||||
marco = users(:marco)
|
||||
assert marco.destroy
|
||||
assert_not User.exists?(marco.id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
require "test_helper"
|
||||
require "tempfile"
|
||||
|
||||
class CampaignImportMatchlivetvLaunchTest < ActiveSupport::TestCase
|
||||
test "imports campaign rows onto MatchLiveTV" do
|
||||
csv = Tempfile.new(["campagna", ".csv"])
|
||||
csv.write(<<~CSV)
|
||||
N.,Regione,Prov.,Società,M/F,Email verificata,Sito / profilo,Streaming rilevato,Evidenza / fit commerciale,Test A/B,Stato invio,Data invio,Esito,Demo / Trial,Conversione,Piano,Fonte contatto / ricerca,Data verifica,Note follow-up
|
||||
1,Piemonte,TO,Vol-Ley Academy Volpiano,F,volley.academy.to@gmail.com,https://www.facebook.com/p/Vol-Ley-Academy-100063526780569/,NON RILEVATO,Academy giovanile,A,DA INVIARE,,,NO,NO,,https://example.com/fonte,2026-08-17,
|
||||
7,Lombardia,MB,Volley Brianza Est,F,ds@volleybrianzaest.it,https://www.volleybrianzaest.it/,SÌ / PARZIALE,U16/U18 nazionale,A,DA INVIARE,,,NO,NO,,https://www.volleybrianzaest.it/,46251,
|
||||
100,Valle d'Aosta,AO,Cogne Aosta Volley,F,cogneaostavolley@gmail.com,https://www.youtube.com/@CogneAostaVolley,SÌ – SPORTCAM,Vivaio prolifico,B,DA INVIARE,,,NO,NO,,https://www.fipav-vda.com/societa,2026-08-17,
|
||||
CSV
|
||||
csv.flush
|
||||
|
||||
result = CampaignImport::MatchlivetvLaunch.new(
|
||||
path: csv.path,
|
||||
user: users(:admin),
|
||||
project: projects(:matchlivetv),
|
||||
wipe: false
|
||||
).call
|
||||
|
||||
assert_empty result.errors, result.errors.inspect
|
||||
assert_equal 3, result.imported
|
||||
|
||||
org = Organization.find_by!(name: "Vol-Ley Academy Volpiano")
|
||||
assert_equal "female", org.team_gender
|
||||
assert_equal "not_detected", org.streaming_status
|
||||
assert_equal 1, org.list_position
|
||||
assert_equal Date.new(2026, 8, 17), org.verified_at
|
||||
assert_equal "campaign", org.lead_source
|
||||
assert org.projects.exists?(code: "matchlivetv")
|
||||
assert_equal "volley.academy.to@gmail.com", org.contacts.primary_first.first.email
|
||||
|
||||
opp = org.campaign_opportunity(projects(:matchlivetv))
|
||||
assert_equal "A", opp.ab_variant
|
||||
assert_equal "to_send", opp.send_status
|
||||
assert_equal "to_contact", opp.pipeline_stage
|
||||
assert_not opp.demo_trial
|
||||
assert_not opp.converted
|
||||
|
||||
brianza = Organization.find_by!(name: "Volley Brianza Est")
|
||||
assert_equal "yes_partial", brianza.streaming_status
|
||||
assert_equal Date.new(2026, 8, 17), brianza.verified_at
|
||||
|
||||
cogne = Organization.find_by!(name: "Cogne Aosta Volley")
|
||||
assert_equal "yes_sportcam", cogne.streaming_status
|
||||
assert_equal "B", cogne.campaign_opportunity(projects(:matchlivetv)).ab_variant
|
||||
ensure
|
||||
csv.close!
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
require "test_helper"
|
||||
|
||||
class MailMergeTest < ActiveSupport::TestCase
|
||||
test "replaces tokens from organization and contact" do
|
||||
html = MailMerge.render(
|
||||
"Ciao {{contatto_nome}} di {{societa}} in {{regione}}",
|
||||
organization: organizations(:acme),
|
||||
contact: contacts(:primary),
|
||||
project: projects(:matchlivetv),
|
||||
opportunity: opportunities(:deal)
|
||||
)
|
||||
|
||||
assert_equal "Ciao Mario Rossi di ASD Test Calcio in Lombardia", html
|
||||
end
|
||||
|
||||
test "falls back to primary contact and org email" do
|
||||
vars = MailMerge.variables_for(organization: organizations(:acme), project: projects(:matchlivetv))
|
||||
assert_equal "Mario Rossi", vars["contatto_nome"]
|
||||
assert_equal "mario@asdtest.example.it", vars["email"]
|
||||
assert_equal "MatchLiveTV", vars["progetto"]
|
||||
assert_equal "Full", vars["piano"]
|
||||
end
|
||||
|
||||
test "test_ab prefers explicit variant" do
|
||||
vars = MailMerge.variables_for(
|
||||
organization: organizations(:acme),
|
||||
project: projects(:matchlivetv),
|
||||
ab_variant: "B"
|
||||
)
|
||||
assert_equal "B", vars["test_ab"]
|
||||
end
|
||||
|
||||
test "unknown tokens become empty string" do
|
||||
assert_equal "X Y", MailMerge.render("X {{sconosciuto}} Y")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
require "test_helper"
|
||||
|
||||
class MailingsRecipientBuilderTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
opportunities(:deal).update!(send_status: "to_send", ab_variant: "A")
|
||||
end
|
||||
|
||||
test "audience to_send includes only pending campaign orgs with valid email" do
|
||||
mailing = create_mailing(audience: "to_send")
|
||||
mailing.rebuild_recipients!
|
||||
|
||||
recipient = mailing.mailing_recipients.find_by!(organization: organizations(:acme))
|
||||
assert_equal "pending", recipient.status
|
||||
assert_equal "mario@asdtest.example.it", recipient.email
|
||||
end
|
||||
|
||||
test "skips organizations without email" do
|
||||
org = organizations(:acme)
|
||||
org.update!(email: nil)
|
||||
org.contacts.update_all(email: nil)
|
||||
|
||||
mailing = create_mailing(audience: "all")
|
||||
mailing.rebuild_recipients!
|
||||
|
||||
recipient = mailing.mailing_recipients.find_by!(organization: org)
|
||||
assert_equal "skipped", recipient.status
|
||||
assert_equal "manca email", recipient.skip_reason
|
||||
end
|
||||
|
||||
test "audience test_a filters by variant" do
|
||||
mailing = create_mailing(audience: "test_a")
|
||||
mailing.rebuild_recipients!
|
||||
assert_equal 1, mailing.mailing_recipients.count
|
||||
|
||||
mailing.update!(audience: "test_b")
|
||||
mailing.rebuild_recipients!
|
||||
assert_equal 0, mailing.mailing_recipients.count
|
||||
end
|
||||
|
||||
test "ab test from_record uses opportunity variant" do
|
||||
create_campaign_org(name: "Beta Volley", email: "beta@example.com", ab_variant: "B", list_position: 2)
|
||||
mailing = create_mailing(
|
||||
audience: "all",
|
||||
ab_test: true,
|
||||
ab_assignment: "from_record",
|
||||
subject_b: "Offerta B {{societa}}",
|
||||
body_html_b: "<p>Versione B</p>"
|
||||
)
|
||||
mailing.rebuild_recipients!
|
||||
|
||||
acme = mailing.mailing_recipients.find_by!(organization: organizations(:acme))
|
||||
beta = mailing.mailing_recipients.joins(:organization).find_by!(organizations: { name: "Beta Volley" })
|
||||
assert_equal "A", acme.ab_variant
|
||||
assert_equal "B", beta.ab_variant
|
||||
assert_match(/Ciao Mario Rossi/, acme.rendered_html)
|
||||
assert_equal "Offerta B Beta Volley", beta.rendered_subject_line
|
||||
assert_match(/Versione B/, beta.rendered_html)
|
||||
end
|
||||
|
||||
test "ab test split alternates A and B" do
|
||||
create_campaign_org(name: "Beta Volley", email: "beta@example.com", ab_variant: "A", list_position: 2)
|
||||
organizations(:acme).update!(list_position: 1)
|
||||
mailing = create_mailing(
|
||||
audience: "all",
|
||||
ab_test: true,
|
||||
ab_assignment: "split",
|
||||
subject_b: "B {{societa}}",
|
||||
body_html_b: "<p>B</p>"
|
||||
)
|
||||
mailing.rebuild_recipients!
|
||||
|
||||
variants = mailing.mailing_recipients.pending.ordered.map(&:ab_variant)
|
||||
assert_equal %w[A B], variants
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
ENV["RAILS_ENV"] ||= "test"
|
||||
require_relative "../config/environment"
|
||||
require "rails/test_help"
|
||||
|
||||
module ActiveSupport
|
||||
class TestCase
|
||||
parallelize(workers: :number_of_processors)
|
||||
fixtures :all
|
||||
end
|
||||
end
|
||||
|
||||
class ActionDispatch::IntegrationTest
|
||||
def login_as(user, password: "password123")
|
||||
post login_path, params: { email: user.email, password: password }
|
||||
end
|
||||
end
|
||||
|
||||
module MailMarketingTestHelper
|
||||
def create_mail_identity(**attrs)
|
||||
MailIdentity.create!(
|
||||
{
|
||||
name: "SMTP test",
|
||||
from_name: "eminuxCRM",
|
||||
from_email: "hello@example.com",
|
||||
smtp_host: "smtp.example.com",
|
||||
smtp_port: 587,
|
||||
smtp_username: "user",
|
||||
smtp_password: "secret",
|
||||
smtp_authentication: "plain",
|
||||
encryption: "starttls",
|
||||
verify_ssl: true,
|
||||
active: true
|
||||
}.merge(attrs)
|
||||
)
|
||||
end
|
||||
|
||||
def create_mailing(project: projects(:matchlivetv), identity: nil, **attrs)
|
||||
Mailing.create!(
|
||||
{
|
||||
project: project,
|
||||
mail_identity: identity || create_mail_identity,
|
||||
name: "Campagna test",
|
||||
subject: "Ciao {{societa}}",
|
||||
body_html: "<p>Ciao {{contatto_nome}} di {{societa}}</p>",
|
||||
audience: "all",
|
||||
interval_seconds: 0,
|
||||
ab_test: false,
|
||||
ab_assignment: "from_record"
|
||||
}.merge(attrs)
|
||||
)
|
||||
end
|
||||
|
||||
def create_campaign_org(name:, email:, ab_variant: nil, send_status: "to_send", list_position: nil)
|
||||
project = projects(:matchlivetv)
|
||||
org = Organization.new(
|
||||
name: name,
|
||||
status: "prospect",
|
||||
organization_type: "societa_sportiva",
|
||||
email: email,
|
||||
list_position: list_position
|
||||
)
|
||||
org.organization_projects.build(project: project)
|
||||
org.save!
|
||||
Contact.create!(
|
||||
organization: org,
|
||||
first_name: "Contatto",
|
||||
last_name: name,
|
||||
email: email,
|
||||
primary_contact: true
|
||||
)
|
||||
Opportunity.create!(
|
||||
organization: org,
|
||||
project: project,
|
||||
name: "Campagna",
|
||||
pipeline_stage: "to_contact",
|
||||
send_status: send_status,
|
||||
ab_variant: ab_variant
|
||||
)
|
||||
org
|
||||
end
|
||||
end
|
||||
|
||||
ActiveSupport::TestCase.include MailMarketingTestHelper
|
||||
ActionDispatch::IntegrationTest.include MailMarketingTestHelper
|
||||
|
||||
Reference in New Issue
Block a user