Files
enterprise-addons/test_marketing_automation/tests/test_performance.py
T

798 lines
38 KiB
Python

import logging
import time
from contextlib import contextmanager
from datetime import timedelta
from unittest.mock import patch
from odoo import fields
from odoo.addons.base.models.ir_cron import MIN_FAILURE_COUNT_BEFORE_DEACTIVATION
from odoo.addons.base.tests.test_ir_cron import CronMixinCase
from odoo.addons.marketing_automation.models.marketing_activity import MarketingActivity
from odoo.addons.marketing_automation.models.marketing_participant import MarketingParticipant
from odoo.addons.marketing_automation.models.marketing_trace import MarketingTrace
from odoo.addons.test_mail.tests.test_performance import BaseMailPerformance
from odoo.addons.test_marketing_automation.tests.common import TestMACommon
from odoo.tests.common import warmup
from odoo.tests import tagged, users
from odoo.tools import mute_logger
_logger = logging.getLogger(__name__)
@tagged('mail_performance', 'marketing_automation', 'post_install', '-at_install')
class MAPerformanceCommon(BaseMailPerformance, TestMACommon, CronMixinCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.date_reference = fields.Datetime.from_string("2024-07-15 10:30:00")
@contextmanager
def trace_duration(self, label):
""" Context manager to measure wall-clock time """
start = time.perf_counter()
yield
spent = time.perf_counter() - start
_logger.info("[Performance] %s: %.4f seconds", label, spent)
@contextmanager
def mockMACalls(self, error_dict=None):
error_dict = error_dict or {}
original_act_execute_on_traces = MarketingActivity.execute_on_traces
original_part_create = MarketingParticipant.create
original_part_search = MarketingParticipant.search
original_part_write = MarketingParticipant.write
original_trace_create = MarketingTrace.create
original_trace_search = MarketingTrace.search
original_trace_write = MarketingTrace.write
# error generation tweaks
# 1. fail at participant creation (e.g. )
fail_part_create_ctr = error_dict.get('fail_part_create_ctr')
fail_act_exec_traces_ctr = error_dict.get('fail_act_exec_traces_ctr')
creation_counter = 0
exec_traces_counter = 0
def _failing_part_create(*args, **kwargs):
nonlocal fail_part_create_ctr
nonlocal creation_counter
nonlocal original_part_create
model, vals_list = args
creation_counter += len(vals_list)
if fail_part_create_ctr and creation_counter >= fail_part_create_ctr:
raise MemoryError('Raising MemoryError')
return original_part_create(model, vals_list, **kwargs)
def _failing_act_exec_traces(*args):
nonlocal fail_act_exec_traces_ctr
nonlocal exec_traces_counter
nonlocal original_part_create
model, traces = args
exec_traces_counter += 1
# make time move forward, notably because crons check time spend in jobs
# and may loop indefinitively
self.frozen_datetime_mock.tick(delta=timedelta(minutes=1))
if fail_act_exec_traces_ctr and exec_traces_counter >= fail_act_exec_traces_ctr:
raise MemoryError('Raising MemoryError')
return original_act_execute_on_traces(model, traces)
with patch.object(MarketingActivity, 'execute_on_traces',
autospec=True, side_effect=_failing_act_exec_traces) as mock_act_execute_on_traces, \
patch.object(MarketingParticipant, 'create',
autospec=True, side_effect=_failing_part_create) as mock_part_create, \
patch.object(MarketingParticipant, 'search',
autospec=True, side_effect=original_part_search) as mock_part_search, \
patch.object(MarketingParticipant, 'write',
autospec=True, side_effect=original_part_write) as mock_part_write, \
patch.object(MarketingTrace, 'create',
autospec=True, side_effect=original_trace_create) as mock_trace_create, \
patch.object(MarketingTrace, 'search',
autospec=True, side_effect=original_trace_search) as mock_trace_search, \
patch.object(MarketingTrace, 'write',
autospec=True, side_effect=original_trace_write) as mock_trace_write:
self._mock_act_execute_on_traces = mock_act_execute_on_traces
self._mock_part_create = mock_part_create
self._mock_part_search = mock_part_search
self._mock_part_write = mock_part_write
self._mock_trace_create = mock_trace_create
self._mock_trace_search = mock_trace_search
self._mock_trace_write = mock_trace_write
yield
@classmethod
def _create_test_campaign(cls, campaign_domain=None):
# --------------------------------------------------
# CAMPAIGN, based on marketing.test.performance
#
# ACT1 MAIL: begin, +1 hour
# ACT1_1 -> opened -> send an SMS after 1h with a promotional link
# ACT1_2 -> not_opened within 1 day-> update description through server action
# ACT2 SMS: begin, +2 hour
# ACT2_1 -> clicked -> send an SMS after 1h with a promotional link
# ACT2_2 -> not_clicked within 1 day-> update description through server action
# ACT3 WA: begin, +3 hour
# ACT3_1 -> replied -> send an SMS after 1h with a promotional link
# ACT3_2 -> not_replied within 1 day-> update description through server action
# ACT4 SA: begin, +4 hour
# --------------------------------------------------
campaign_domain = campaign_domain or [("name", "!=", "Invalid")]
test_campaign = cls.env['marketing.campaign'].with_user(cls.user_marketing_automation).create({
"domain": campaign_domain,
"model_id": cls.env['ir.model']._get_id("marketing.test.performance"),
"name": "Test Campaign",
})
# ACT1: send a mailing
act1_begin_mailing = cls._create_activity_mail(
test_campaign,
mailing_values={
"email_from": cls.user_marketing_automation.email_formatted,
"keep_archives": True,
},
act_values={
"interval_number": 1,
"interval_type": "hours",
"trigger_type": "begin",
},
)
# ACT1_1: send an SMS 1 hour after 'open' event
_act1_1_sms = cls._create_activity_mail(
test_campaign,
mailing_values={
"body_plaintext": "SMS for {{ object.name }}: please confirm on https://test.example.com/confirm_mail",
"mailing_type": "sms",
"sms_allow_unsubscribe": True,
},
act_values={
"interval_number": 1,
"interval_type": "hours",
"parent_id": act1_begin_mailing.id,
"trigger_type": "mail_open",
},
)
# ACT_1_2: update description if not opened after 1 day
# created by admin, should probably not give rights to marketing
_act1_2_sa = cls._create_activity_sa(
test_campaign,
"records.write({'selection_field': 'key1'})",
act_values={
"activity_domain": [("email_from", "!=", False)],
"interval_number": 1,
"interval_type": "days",
"parent_id": act1_begin_mailing.id,
"trigger_type": "mail_not_open",
},
)
# ACT2: send a SMS mailing
act2_begin_sms = cls._create_activity_mail(
test_campaign,
mailing_values={
"body_plaintext": "SMS for {{ object.name }}: mega promo on https://test.example.com/promo",
"mailing_type": "sms",
"keep_archives": True,
},
act_values={
"interval_number": 2,
"interval_type": "hours",
"trigger_type": "begin",
},
)
# ACT2_1: send an SMS 1 hour after a 'click' event
_act2_1_sms = cls._create_activity_mail(
test_campaign,
mailing_values={
"body_plaintext": "SMS for {{ object.name }}: please confirm on https://test.example.com/confirm_sms",
"mailing_type": "sms",
"sms_allow_unsubscribe": True,
},
act_values={
"interval_number": 1,
"interval_type": "hours",
"parent_id": act2_begin_sms.id,
"trigger_type": "sms_click",
},
)
# ACT2_2: update description if not opened after 1 day
_act2_2_sa = cls._create_activity_sa(
test_campaign,
"records.write({'selection_field': 'key2'})",
act_values={
"activity_domain": [("phone", "!=", False)],
"interval_number": 1,
"interval_type": "days",
"parent_id": act2_begin_sms.id,
"trigger_type": "sms_not_click",
},
)
# ACT2: send a whatsapp
act3_begin_wa = cls._create_activity_wa(
test_campaign,
template_values={
'name': f'TestTemplate for {test_campaign.id}',
},
act_values={
"interval_number": 3,
"interval_type": "hours",
"trigger_type": "begin",
},
)
# ACT3_1: send an SMS 1 hour after a 'replied' event
_act3_1_sms = cls._create_activity_mail(
test_campaign,
mailing_values={
"body_plaintext": "SMS for {{ object.name }}: please confirm on https://test.example.com/confirm_wa",
"mailing_type": "sms",
"sms_allow_unsubscribe": True,
},
act_values={
"interval_number": 1,
"interval_type": "hours",
"parent_id": act3_begin_wa.id,
"trigger_type": "whatsapp_replied",
},
)
# ACT3_2: update description if not replied after 1 day
_act3_2_sa = cls._create_activity_sa(
test_campaign,
"records.write({'selection_field': 'key3'})",
act_values={
"activity_domain": [("phone", "!=", False)],
"interval_number": 1,
"interval_type": "days",
"parent_id": act3_begin_wa.id,
"trigger_type": "whatsapp_not_replied",
},
)
# ACT4: run a SA
_act4 = cls._create_activity_sa(
test_campaign,
"records.write({'selection_field': 'key1'})",
act_values={
"interval_number": 4,
"interval_type": "hours",
"trigger_type": "begin",
},
)
return test_campaign
@classmethod
def _create_perf_test_records(cls, count=200, include_void=True, include_dupe=False):
# --------------------------------------------------
# TEST RECORDS, using marketing.test.performance
#
# 200 times (or 'count')
# - 3 records with partners
# - 1 records wo partner, but email/mobile
# - 1 record wo partner/email/mobile
# AKA 1000 records
# --------------------------------------------------
return cls._create_marketauto_records(
model="marketing.test.performance",
count=count,
include_void=include_void,
include_dupe=include_dupe,
)
@tagged('mail_performance', 'ma_cron', 'post_install', '-at_install')
class TestMACron(MAPerformanceCommon):
""" Cron behavior for marketing campaigns
Quick timing info
Vers -- cnt -- aver
19.0 10000 37 / 50 (unique)
19.0 1000 1.7 / 1.9 (unique)
19.0 100 0.15
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
# test records
cls.total_size = 100
cls.test_records = cls._create_perf_test_records(count=int(cls.total_size / 5), include_void=False, include_dupe=True)
cls.dupe_size = cls.total_size / 5
# test campaign, same for all tests
with cls.mock_datetime_and_now(cls, cls.date_reference):
cls.campaign = cls._create_test_campaign(
campaign_domain=[("name", "!=", "Invalid"), ("selection_field", "!=", "key3")],
)
cls.campaign.write({'state': 'running'})
cls.begin_activities = cls.campaign.marketing_activity_ids.filtered(lambda a: a.trigger_type == 'begin')
def test_assert_initial_values(self):
""" Common initial tests for this class """
self.assertEqual(len(self.test_records), self.total_size)
self._assert_cron_progress(self.cron_ma_sync_participants, 0)
self._assert_cron_progress(self.cron_ma_execute_activities, 0)
self.assertEqual(len(self.begin_activities), 4)
# crons are clean
self._assert_cron_state(self.cron_ma_sync_participants)
self._assert_cron_state(self.cron_ma_execute_activities)
def test_cron_execute_activities(self):
""" Test 'ir_cron_campaign_execute_activities' cron """
cron = self.cron_ma_execute_activities
campaign = self.campaign
with self.mock_datetime_and_now(self.date_reference):
campaign.sync_participants()
self.assertEqual(len(campaign.participant_ids), self.total_size)
self.assertActivityScheduled(self.begin_activities, self.test_records)
with self.trace_duration('Cron Complete'), \
self.mock_datetime_and_now(self.date_reference + timedelta(hours=4)), \
self.mock_mail_gateway(), self.mockSMSGateway(), \
self.mockWhatsappGateway(), \
self.capture_triggers('marketing_automation.ir_cron_campaign_sync_participants') as captured_triggers_sync, \
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers_execute:
cron.method_direct_trigger()
# progression check
self.assertActivityProcessed(self.begin_activities, self.test_records)
# cron usage
self._assert_cron_state(
cron, failure_count=0, first_failure_date=False,
lastcall=self.date_reference + timedelta(hours=4),
)
self.assertEqual(cron.nextcall, self.date_reference + timedelta(hours=5), 'Rescheduled for later, not asap (to check for time)')
self._assert_cron_progress(cron, 1, remaining=0)
# triggers: for children traces (_generate_children_traces); those are
# scheduled one day after the 'begin' trace, see setup
self.assertEqual(len(captured_triggers_execute.records), 3,
"Cron triggers for child activities, added in '_generate_children_traces'")
self.assertEqual(captured_triggers_execute.records.cron_id, cron)
self.assertEqual(
sorted(captured_triggers_execute.records.mapped('call_at')),
[self.date_reference + timedelta(days=1, hours=1), self.date_reference + timedelta(days=1, hours=2), self.date_reference + timedelta(days=1, hours=3)],
)
# sync untouched
self.assertEqual(len(captured_triggers_sync.records), 0,
'Run did not schedule anything (nor commit progress)')
def test_cron_execute_activities_fail(self):
""" Test 'ir_cron_campaign_execute_activities' cron managing failures """
cron = self.cron_ma_execute_activities
campaign = self.campaign
with self.mock_datetime_and_now(self.date_reference):
campaign.sync_participants()
self.assertEqual(len(campaign.participant_ids), self.total_size)
self.assertActivityScheduled(self.begin_activities, self.test_records)
# fail when calling execute_on_traces, simulating a MemoryError
with self.mockMACalls({'fail_act_exec_traces_ctr': 2}), \
mute_logger('odoo.addons.base.models.ir_cron'),\
self.mock_datetime_and_now(self.date_reference + timedelta(hours=4)), \
self.mock_mail_gateway(), self.mockSMSGateway(), \
self.mockWhatsappGateway(), \
self.capture_triggers('marketing_automation.ir_cron_campaign_sync_participants') as captured_triggers_sync, \
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers_execute:
cron.method_direct_trigger()
# progression check
self.assertActivityScheduled(self.begin_activities, self.test_records) # 'Due to fail, traces has not been processed'
# cron usage
self._assert_cron_state(
cron, failure_count=1,
first_failure_date=self.date_reference + timedelta(hours=4),
lastcall=self.date_reference + timedelta(hours=4),
)
self.assertEqual(cron.nextcall, self.date_reference + timedelta(hours=5), 'Rescheduled for later, not asap (to check for time)')
self._assert_cron_progress(cron, 1, remaining=0)
self.assertEqual(len(captured_triggers_sync.records), 0,
'Run did not schedule anything (nor commit progress)')
# sync untouched
self.assertEqual(len(captured_triggers_execute.records), 0,
'Run did not schedule anything (nor commit progress)')
def test_cron_execute_activities_fail_mixed(self):
""" Test 'ir_cron_campaign_execute_activities' cron managing failures
on working and failing campaigns, aka check iterative support """
cron = self.cron_ma_execute_activities
campaign = self.campaign
begin_activities = self.begin_activities
campaign_2 = campaign.copy({'state': 'running'})
begin_activities_2 = campaign_2.marketing_activity_ids.filtered(lambda a: a.trigger_type == 'begin')
with self.mock_datetime_and_now(self.date_reference):
(campaign + campaign_2).sync_participants()
self.assertEqual(len(campaign.participant_ids), self.total_size)
self.assertEqual(len(campaign_2.participant_ids), self.total_size)
self.assertActivityScheduled(begin_activities, self.test_records)
self.assertActivityScheduled(begin_activities_2, self.test_records)
# fail when calling execute_on_traces, simulating a MemoryError during
# 'campaign' 2d activity (aka 6th activity)
with self.mockMACalls({'fail_act_exec_traces_ctr': 6}), \
mute_logger('odoo.addons.base.models.ir_cron'),\
self.mock_datetime_and_now(self.date_reference + timedelta(hours=4)), \
self.mock_mail_gateway(), self.mockSMSGateway(), \
self.mockWhatsappGateway(), \
self.capture_triggers('marketing_automation.ir_cron_campaign_sync_participants') as captured_triggers_sync, \
self.capture_triggers('marketing_automation.ir_cron_campaign_execute_activities') as captured_triggers_execute:
cron.method_direct_trigger()
# progression check
self.assertActivityScheduled(self.begin_activities, self.test_records) # 'Due to fail, traces has not been processed'
self.assertActivityScheduled(begin_activities_2, self.test_records) # 'Due to fail, traces has not been processed'
# cron usage
self._assert_cron_state(
cron, failure_count=1,
first_failure_date=self.date_reference + timedelta(hours=4),
lastcall=self.date_reference + timedelta(hours=4),
)
self.assertEqual(cron.nextcall, self.date_reference + timedelta(hours=5), 'Rescheduled for later, not asap (to check for time)')
self._assert_cron_progress(cron, 1, remaining=0)
self.assertEqual(len(captured_triggers_sync.records), 0,
'Run did not schedule anything (nor commit progress)')
# sync untouched
self.assertEqual(len(captured_triggers_execute.records), 0,
'Run did not schedule anything (nor commit progress)')
def test_cron_synchronize_participants(self):
""" Test 'ir_cron_campaign_sync_participants' cron """
cron = self.cron_ma_sync_participants
campaign = self.campaign
with self.trace_duration('Cron Complete'), \
self.mock_datetime_and_now(self.date_reference), \
self.capture_triggers('marketing_automation.ir_cron_campaign_sync_participants') as captured_triggers:
cron.method_direct_trigger()
# progression check
self.assertEqual(
len(campaign.participant_ids), self.total_size,
'Synchronized everything, as process is not iterative')
# cron usage: no failure detecter
self._assert_cron_state(cron, failure_count=0, first_failure_date=False, lastcall=self.date_reference)
self.assertEqual(cron.nextcall, self.cron_last_call + timedelta(hours=12), 'Rescheduled for later, not asap')
self._assert_cron_progress(cron, 1, remaining=0)
self.assertEqual(len(captured_triggers.records), 0,
'Run did not schedule anything (nor commit progress)')
def test_cron_synchronize_participants_fail(self):
""" Test 'ir_cron_campaign_sync_participants' cron managing failures """
cron = self.cron_ma_sync_participants
campaign = self.campaign
with self.mockMACalls({'fail_part_create_ctr': 75}), \
mute_logger('odoo.addons.base.models.ir_cron'),\
self.mock_datetime_and_now(self.date_reference), \
self.capture_triggers('marketing_automation.ir_cron_campaign_sync_participants') as captured_triggers:
cron.method_direct_trigger()
# progression check
self.assertEqual(
len(campaign.participant_ids), 0,
'Failed, everything is rollbacked (which could be improved)')
# cron usage: failure detected once
self._assert_cron_state(cron, active=True, failure_count=1, first_failure_date=self.date_reference, lastcall=self.date_reference)
self.assertEqual(cron.nextcall, self.cron_last_call + timedelta(hours=12), 'Rescheduled for later, not asap')
self._assert_cron_progress(cron, 1, remaining=0)
self.assertEqual(len(captured_triggers.records), 0,
'Run did not schedule anything (nor commit progress)')
# some more fails (see MIN_FAILURE_COUNT_BEFORE_DEACTIVATION) under limited
# timeframe (see MIN_DELTA_BEFORE_DEACTIVATION) should deactivate it (such sad)
for idx in range(MIN_FAILURE_COUNT_BEFORE_DEACTIVATION - 1):
with self.mockMACalls({'fail_part_create_ctr': 75}), \
mute_logger('odoo.addons.base.models.ir_cron'),\
self.mock_datetime_and_now(self.date_reference + timedelta(days=(7 + idx))):
cron.method_direct_trigger()
# cron deactivated
self.assertFalse(cron.active, 'Should be deactivated after successive fails')
def test_cron_synchronize_participants_fail_mixed(self):
""" Test 'ir_cron_campaign_sync_participants' cron managing failures
on working and failing campaigns, aka check iterative support """
cron = self.cron_ma_sync_participants
campaign = self.campaign
campaign_2 = campaign.copy({'state': 'running'})
with self.mockMACalls({'fail_part_create_ctr': 175}), \
mute_logger('odoo.addons.base.models.ir_cron'),\
self.mock_datetime_and_now(self.date_reference), \
self.capture_triggers('marketing_automation.ir_cron_campaign_sync_participants') as captured_triggers:
cron.method_direct_trigger()
# progression check
self.assertEqual(
len(campaign.participant_ids), 0,
'Failed, everything is rollbacked (which could be improved)')
self.assertEqual(
len(campaign_2.participant_ids), 0,
'Failed, everything is rollbacked (which could be improved)')
# cron usage
self._assert_cron_state(
cron, failure_count=1,
first_failure_date=self.date_reference,
lastcall=self.date_reference,
)
self.assertEqual(cron.nextcall, self.date_reference + timedelta(hours=1), 'Rescheduled for later, not asap (to check for time)')
self._assert_cron_progress(cron, 1, remaining=0)
self.assertEqual(len(captured_triggers.records), 0,
'Run did not schedule anything (nor commit progress)')
def test_cron_synchronize_participants_unique(self):
""" Test 'ir_cron_campaign_sync_participants' cron, dealing with field
unicity. """
cron = self.cron_ma_sync_participants
campaign = self.campaign
with self.mock_datetime_and_now(self.date_reference):
campaign.write({'unique_field_id': self.env['ir.model.fields']._get(self.test_records._name, 'email_from').id})
with self.trace_duration('Cron Complete Unique Check'), \
self.mock_datetime_and_now(self.date_reference), \
self.capture_triggers('marketing_automation.ir_cron_campaign_sync_participants') as captured_triggers:
cron.method_direct_trigger()
# progression check
self.assertEqual(
len(campaign.participant_ids), self.total_size - self.dupe_size,
'Synchronized everything, as process is not iterative, minus duplicates')
# cron usage
self._assert_cron_state(cron, active=True, failure_count=0, first_failure_date=False, lastcall=self.date_reference)
self.assertEqual(cron.nextcall, self.cron_last_call + timedelta(hours=12), 'Rescheduled for later, not asap')
self._assert_cron_progress(cron, 1, remaining=0)
self.assertEqual(len(captured_triggers.records), 0,
'Run did not schedule anything (nor commit progress)')
def test_cron_synchronize_participants_unique_quick(self):
""" Quick testing of unique feature support, easing early fault detection
in queries / algorithm """
cron = self.cron_ma_sync_participants
campaign = self.campaign
campaign_2 = campaign.copy({'state': 'running'})
test_records = self.env['marketing.test.performance'].create([
{'email_from': '[email protected]'},
{'email_from': '[email protected]'}, # already participating
{'email_from': '[email protected]'},
{'email_from': '[email protected]'}, # duplicate inside new to consider
{'email_from': '[email protected]'}, # duplicate of already participating
])
_existing = self.env['marketing.participant'].create({'campaign_id': campaign.id, 'res_id': test_records[1].id})
_existing2 = self.env['marketing.participant'].create({'campaign_id': campaign_2.id, 'res_id': test_records[1].id})
with self.mock_datetime_and_now(self.date_reference):
(campaign + campaign_2).write({
'domain': [('email_from', 'ilike', 'zboing.example.com')],
'unique_field_id': self.env['ir.model.fields']._get(self.test_records._name, 'email_from').id,
})
with self.capture_triggers('marketing_automation.ir_cron_campaign_sync_participants') as captured_triggers:
cron.method_direct_trigger()
for c in campaign + campaign_2:
self.assertEqual(len(c.participant_ids), 3)
self.assertEqual(
sorted(test_records.browse(c.participant_ids.mapped('res_id')).mapped('email_from')),
[f'from.{idx}@zboing.example.com' for idx in range(1, 4)]
)
self._assert_cron_state(cron, active=True, failure_count=0, first_failure_date=False, lastcall=self.date_reference)
self.assertEqual(cron.nextcall, self.cron_last_call + timedelta(hours=12), 'Rescheduled for later, not asap')
self._assert_cron_progress(cron, 1, remaining=0)
self.assertEqual(len(captured_triggers.records), 0,
'Run did not schedule anything (nor commit progress)')
@tagged('mail_performance', 'post_install', '-at_install')
class TestMAPerformance(MAPerformanceCommon):
""" Simpler tests that do not require an heavy campaign in data """
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.test_records = cls._create_perf_test_records()
def setUp(self):
super().setUp()
with self.mock_datetime_and_now(self.date_reference):
self.test_campaign = self._create_test_campaign()
self._launch_campaign(self.test_campaign, date_reference=self.date_reference)
def test_assert_initial_values(self):
""" Check initial values for tests """
self.assertEqual(len(self.test_records), 1000)
self.assertFalse(self.test_campaign.require_sync)
self.assertEqual(self.test_campaign.last_sync_date, self.date_reference)
self.assertEqual(
len(self.test_campaign.marketing_activity_ids.trace_ids),
len(self.test_records) * 4,
"Should have generated one trace / begin activity / campaign record",
)
self.assertEqual(
len(self.test_campaign.participant_ids),
len(self.test_records),
"Should have generated one participant / campaign record",
)
@warmup
def test_campaign_sync_participants_launch(self):
""" Test 'sync_participants' at campaign beginning. It is called by a
cron regularly, or manually on campaign form view to start the campaign
directly. It creates participants and their starting trace. """
with self.mock_datetime_and_now(self.date_reference):
campaign = self._create_test_campaign()
self.assertEqual(len(self.test_records), 1000)
# local: 1099
# runbot: 1099, taking 100 more to avoid runbot issues in stable
with self.assertQueryCount(1099 + 100), \
self.mock_datetime_and_now(self.date_reference), \
self.mockMACalls():
campaign.sync_participants()
# sanity check
self.assertEqual(len(campaign.participant_ids), len(self.test_records))
# performance check
self.assertEqual(
self._mock_part_create.call_count, 10,
'Sync participants: created by batches of 100')
self.assertEqual(self._mock_part_search.call_count, 0)
self.assertEqual(self._mock_part_write.call_count, 1000,
'Sync participants: participants updated one by one,could be improved probably')
self.assertEqual(self._mock_trace_create.call_count, 1000,
'Sync participants: trace created one by one, could be improved')
self.assertEqual(self._mock_trace_search.call_count, 0)
self.assertEqual(self._mock_trace_write.call_count, 0)
@mute_logger(
'odoo.addons.mass_mailing_sms.models.mailing_mailing',
'odoo.addons.mass_mailing.models.mailing',
)
@warmup
def test_execute_activities_then_sync_participants(self):
""" Test 'execute_activities' on all activity types. Then test
'sync_participants' on a running campaign, aka updating participants
based on DB state. """
campaign = self.test_campaign
# local: 16864
# runbot: 16869, taking 100 more to avoid runbot issues in stable
# hours+4 -> is going to trigger all 4 begin activities
with self.assertQueryCount(16869 + 100), \
self.mock_datetime_and_now(self.date_reference + timedelta(hours=4)), \
self.mock_mail_gateway(), self.mock_mail_app(), self.mockSMSGateway(), \
self.mockWhatsappGateway(), self.patchWhatsappCronTrigger(), \
self.mockMACalls():
campaign.execute_activities()
# produced side records check
self.assertEqual(
len(self._new_mails), len(self.test_records) - 200,
"Should have sent one email / campaign record, minus canceled ones "
"(no valid email -> no email created), aka 1000 - 200",
)
self.assertEqual(
len(self._new_sms), len(self.test_records) - 200,
"Should have sent one SMS / campaign record, minus canceled ones "
"(no valid number -> no sms created), aka 1000 - 200",
)
self.assertEqual(
len(self._new_wa_msg), len(self.test_records),
"Should have sent one WA / campaign record, aka 1000",
)
self.assertEqual(
len(campaign.marketing_activity_ids.trace_ids),
len(self.test_records) * 10,
"Should have: 4 begin activity + 6 sub activities / record, aka 10 * 1000",
)
# performance check
self.assertEqual(self._mock_act_execute_on_traces.call_count, 8,
'4 activities * 2 batch / activity')
self.assertEqual(self._mock_part_create.call_count, 0)
self.assertEqual(self._mock_part_search.call_count, 0)
self.assertEqual(self._mock_part_write.call_count, 8,
'Where do those come from anyway ?')
self.assertEqual(self._mock_trace_create.call_count, 6000,
'Child trace creation: still done sequentially')
self.assertEqual(self._mock_trace_search.call_count, 30,
'Yay 2*15. Probably because of batches inside batches. To check.')
self.assertEqual(self._mock_trace_write.call_count, 1014,
'Where do those come from anyway ?')
# side records performance check
self.assertEqual(self.mail_mail_create_mocked.call_count, 20,
'Done by batch size inside activity execution batch (2 * 10)')
self.assertEqual(self._mock_mail_message_create.call_count, 1022,
'Sequential creation spotted notably in whatsapp')
self.assertEqual(self._mock_sms_create.call_count, 2,
'Done by activity execution batch (500 currently)')
self.assertEqual(self._mock_wa_msg_create.call_count, 2,
'Done by activity execution batch (500 currently)')
self.assertEqual(self._mock_wa_msg_write.call_count, 2000,
'Sequential update of whatsapp message (during creation and send)')
# now create 10*5 records, unlink other 50 records, observe performance
self.test_records_new = self._create_perf_test_records(count=10)
self.assertEqual(len(self.test_records_new), 50)
self.test_records[:50].unlink()
# local: 74
# runbot: 75, taking 10 more to avoid runbot issues in stable
with self.assertQueryCount(75 + 10), \
self.mock_datetime_and_now(self.date_reference + timedelta(hours=24)), \
self.mockMACalls():
campaign.sync_participants()
# sanity check
self.assertEqual(
len(campaign.participant_ids), 1050,
'Removed records still have their participant, set as unlinked'
)
# performance check
self.assertEqual(self._mock_part_create.call_count, 1)
self.assertEqual(self._mock_part_search.call_count, 1)
self.assertEqual(self._mock_part_write.call_count, 51)
self.assertEqual(self._mock_trace_create.call_count, 50,
'Created sequentially')
self.assertEqual(self._mock_trace_search.call_count, 1)
self.assertEqual(self._mock_trace_write.call_count, 1)
@users("user_marketing_automation")
@warmup
def test_update_participants(self):
""" 'action_update_participants' can be called manually when the
workflow has been modified on an ongoing marketing campaign. """
campaign = self.test_campaign.with_env(self.env)
# update activities, should update scheduled dates
activity_mail = campaign.marketing_activity_ids.filtered(
lambda a: a.trigger_type == "begin" and a.activity_type == "email"
)
self.assertTrue(activity_mail)
activity_mail.write({
"interval_number": 2,
})
# create new activities that are about to trigger the "require sync" flag !
new_activity_begin = self._create_activity_mail(
campaign,
user=self.env.user,
act_values={
"interval_number": 1,
"interval_type": "days",
"name": "New begin MAIL activity",
"trigger_type": "begin",
},
)
_new_activity_sub = self._create_activity_sa(
campaign,
"records.write({'selection_field': 'key3'})",
act_values={
"activity_domain": [("email_from", "!=", False)],
"interval_number": 1,
"interval_type": "days",
"parent_id": new_activity_begin.id,
"trigger_type": "mail_not_open",
},
)
# local: 1055
# runbot: 1055, taking 100 more to avoid runbot issues in stable
with self.assertQueryCount(1055 + 100), \
self.mockMACalls(), \
self.mock_datetime_and_now(self.date_reference + timedelta(hours=2)):
campaign.action_update_participants()
self.assertEqual(self._mock_part_create.call_count, 0)
self.assertEqual(self._mock_part_search.call_count, 5)
self.assertEqual(self._mock_part_write.call_count, 7)
self.assertEqual(self._mock_trace_create.call_count, 1000,
'New traces for new begin activity created sequentially')
self.assertEqual(self._mock_trace_search.call_count, 13)
self.assertEqual(self._mock_trace_write.call_count, 1000,
'Looks like sequential update')