798 lines
38 KiB
Python
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')
|