19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -24,6 +24,7 @@ from . import test_install
from . import test_avatar_mixin
from . import test_init
from . import test_ir_actions
from . import test_ir_asset
from . import test_ir_attachment
from . import test_ir_cron
from . import test_ir_filters

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<asset id="base.test_asset_tag_aaa" name="Test asset tag (init active => keep active => update active)">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_aii" name="Test asset tag (init active => make inactive => update inactive)">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_aia" name="Test asset tag (init active => make inactive => update active)">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
<field name="active">True</field> <!-- Take into account during update -->
</asset>
<asset id="base.test_asset_tag_iii" name="Test asset tag (init inactive => keep inactive => update inactive)" active="False">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_iaa" name="Test asset tag (init inactive => make active => update active)" active="False">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_prepend" name="Test asset tag with directive">
<bundle directive="prepend">test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
</asset>
<asset id="base.test_asset_tag_extra" name="Test asset tag with extra field">
<bundle>test_asset_bundle</bundle>
<path>base/tests/something.scss</path>
<field name="sequence" eval="17"/>
</asset>
</odoo>

View file

@ -89,7 +89,7 @@ class BaseCommon(TransactionCase):
@classmethod
def get_default_groups(cls):
return cls.env['res.users']._default_groups()
return cls.env.ref('base.group_user')
@classmethod
def setup_main_company(cls, currency_code='USD'):
@ -256,7 +256,7 @@ class SavepointCaseWithUserDemo(TransactionCase):
'name': 'Austin Kennedy', # Tom Ruiz
})],
}, {
'name': 'Pepper Street', # 'Deco Addict',
'name': 'Pepper Street', # 'Acme Corporation',
'state_id': cls.env.ref('base.state_us_2').id,
'child_ids': [Command.create({
'name': 'Liam King', # 'Douglas Fletcher',

View file

@ -49,6 +49,7 @@ reportgz = False
screencasts =
screenshots = /tmp/odoo_tests
server_wide_modules = base,rpc,web
skip_auto_install = False
smtp_password =
smtp_port = 25
smtp_server = localhost

View file

@ -159,6 +159,32 @@ class TestParentStore(TransactionCase):
self.assertEqual(len(old_struct), 4, "After duplication, previous record must have old childs records only")
self.assertFalse(new_struct & old_struct, "After duplication, nodes should not be mixed")
def test_missing_parent(self):
""" Missing parent id should not raise an error. """
# Missing parent with _parent_store
new_cat0 = self.cat0.copy()
records = new_cat0.search([('parent_id', 'parent_of', 999999999)])
self.assertEqual(len(records), 0)
# Missing parent without _parent_store
category = self.env['res.partner.category']
self.patch(self.env.registry['res.partner.category'], '_parent_store', False)
records = category.search([('parent_id', 'child_of', 999999999)])
self.assertEqual(len(records), 0)
def test_missing_child(self):
""" Missing child id should not raise an error. """
# Missing child with _parent_store
new_cat0 = self.cat0.copy()
records = new_cat0.search([('parent_id', 'child_of', 999999999)])
self.assertEqual(len(records), 0)
# Missing child without _parent_store
category = self.env['res.partner.category']
self.patch(self.env.registry['res.partner.category'], '_parent_store', False)
records = category.search([('parent_id', 'child_of', 999999999)])
self.assertEqual(len(records), 0)
def test_duplicate_children_01(self):
""" Duplicate the children then reassign them to the new parent (1st method). """
new_cat1 = self.cat1.copy()

View file

@ -1,15 +1,12 @@
import io
import os
import re
import subprocess as sp
import sys
import textwrap
import time
import unittest
from pathlib import Path
from odoo.cli.command import commands, load_addons_commands, load_internal_commands
from odoo.tests import BaseCase, TransactionCase
from odoo.tests import BaseCase
from odoo.tools import config, file_path
@ -132,55 +129,3 @@ class TestCommand(BaseCase):
# we skip local variables as they differ based on configuration (e.g.: if a database is specified or not)
lines = [line for line in shell.stdout.read().splitlines() if line.startswith('>>>')]
self.assertEqual(lines, [">>> Hello from Python!", '>>> '])
class TestCommandUsingDb(TestCommand, TransactionCase):
@unittest.skipIf(
os.name != 'posix' and sys.version_info < (3, 12),
"os.set_blocking on files only available in windows starting 3.12",
)
def test_i18n_export(self):
# i18n export is a process that takes a long time to run, we are
# not interrested in running it in full, we are only interrested
# in making sure it starts correctly.
#
# This test only asserts the first few lines and then SIGTERM
# the process. We took the challenge to write a cross-platform
# test, the lack of a select-like API for Windows makes the code
# a bit complicated. Sorry :/
expected_text = textwrap.dedent("""\
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# \t* base
""").encode()
proc = self.popen_command(
'i18n', 'export', '-d', self.env.cr.dbname, '-o', '-', 'base',
# ensure we get a io.FileIO and not a buffered or text shit
text=False, bufsize=0,
)
# Feed the buffer for maximum 15 seconds.
buffer = io.BytesIO()
timeout = time.monotonic() + 15
os.set_blocking(proc.stdout.fileno(), False)
while buffer.tell() < len(expected_text) and time.monotonic() < timeout:
if chunk := proc.stdout.read(len(expected_text) - buffer.tell()):
buffer.write(chunk)
else:
# would had loved to use select() for its timeout, but
# select doesn't work on files on windows, use a flat
# sleep instead: not great, not terrible.
time.sleep(.1)
proc.terminate()
try:
proc.wait(timeout=5)
except sp.TimeoutExpired:
proc.kill()
raise
self.assertEqual(buffer.getvalue(), expected_text,
"The subprocess did not write the prelude in under 15 seconds.")

View file

@ -152,6 +152,7 @@ class TestConfigManager(TransactionCase):
# advanced
'dev_mode': [],
'skip_auto_install': False,
'stop_after_init': False,
'osv_memory_count_limit': 0,
'transient_age_limit': 1.0,
@ -270,6 +271,7 @@ class TestConfigManager(TransactionCase):
# advanced
'dev_mode': ['xml'], # blacklist for save, read from the config file
'stop_after_init': False,
'skip_auto_install': False,
'osv_memory_count_limit': 71,
'transient_age_limit': 4.0,
'max_cron_threads': 4,
@ -390,6 +392,7 @@ class TestConfigManager(TransactionCase):
'init': {},
'publisher_warranty_url': 'http://services.odoo.com/publisher-warranty/',
'save': False,
'skip_auto_install': False,
'stop_after_init': False,
# undocummented options
@ -570,6 +573,7 @@ class TestConfigManager(TransactionCase):
# advanced
'dev_mode': ['xml', 'reload'],
'skip_auto_install': False,
'stop_after_init': True,
'osv_memory_count_limit': 71,
'transient_age_limit': 4.0,
@ -629,6 +633,7 @@ class TestConfigManager(TransactionCase):
'upgrade_path': [],
'pre_upgrade_scripts': [],
'server_wide_modules': ['web', 'base', 'mail'],
'skip_auto_install': False,
'data_dir': '/tmp/data-dir',
# HTTP

View file

@ -1236,14 +1236,14 @@ class TestExpression(SavepointCaseWithUserDemo, TransactionExpressionCase):
# indirect search via m2o
Partner = self.env['res.partner']
deco_addict = self._search(Partner, [('name', '=', 'Pepper Street')])
acme_corp = self._search(Partner, [('name', '=', 'Pepper Street')])
not_be = self._search(Partner, [('country_id', '!=', 'Belgium')])
self.assertNotIn(deco_addict, not_be)
self.assertNotIn(acme_corp, not_be)
Partner = Partner.with_context(lang='fr_FR')
not_be = self._search(Partner, [('country_id', '!=', 'Belgique')])
self.assertNotIn(deco_addict, not_be)
self.assertNotIn(acme_corp, not_be)
def test_or_with_implicit_and(self):
# Check that when using expression.OR on a list of domains with at least one
@ -1793,36 +1793,78 @@ class TestQueries(TransactionCase):
''']):
Model.search([])
@mute_logger('odoo.models.unlink')
def test_access_rules_active_test(self):
Model = self.env['res.partner'].with_user(self.env.ref('base.user_admin'))
self.env['ir.rule'].search([]).unlink()
PartnerCateg = self.env['res.partner.category']
model_id = self.env['ir.model']._get('res.partner.category').id
self.env['ir.rule'].search([('model_id', '=', model_id)]).unlink()
self.env['ir.rule'].create([{
'name': 'partner users rule',
'model_id': self.env['ir.model']._get('res.partner').id,
'domain_force': str([('user_ids.login', 'like', '%@%')]),
'name': 'categ childs rule',
'model_id': model_id,
'domain_force': str([('child_ids', 'not any', [('name', 'ilike', 'private')])]),
}, {
'name': 'partners rule',
'model_id': self.env['ir.model']._get('res.partner').id,
'domain_force': str([('commercial_partner_id.name', '=', 'John')]),
'name': 'categ rule',
'model_id': model_id,
'domain_force': str([('parent_id.name', 'ilike', 'public')]),
}])
Model.search([])
pub_active, pub_inactive, pri_active, pri_inactive = PartnerCateg.create([
{'name': 'public active', 'active': True},
{'name': 'public inactive', 'active': False},
{'name': 'private active', 'active': True},
{'name': 'private inactive', 'active': False},
])
accessible_records = PartnerCateg.create([
{'name': 'a1', 'parent_id': pub_active.id},
{'name': 'a2', 'parent_id': pub_inactive.id},
{'name': 'a3', 'parent_id': pub_active.id, 'child_ids': [Command.create({'name': 'not PRI'})]},
])
inaccessible_records = PartnerCateg.create([
{'name': 'ua1'}, # No public parent
{'name': 'ua2', 'parent_id': pri_active.id},
{'name': 'ua3', 'parent_id': pub_active.id, 'child_ids': [Command.link(pri_active.id)]},
{'name': 'ua4', 'parent_id': pub_active.id, 'child_ids': [Command.link(pri_inactive.id)]},
])
records = accessible_records + inaccessible_records
domain = [('id', 'in', records.ids)]
PartnerCateg = PartnerCateg.with_user(self.env.ref('base.user_admin'))
PartnerCateg.search(domain) # warmup
with self.assertQueries(['''
SELECT "res_partner"."id"
FROM "res_partner"
LEFT JOIN "res_partner" AS "res_partner__commercial_partner_id"
ON ("res_partner"."commercial_partner_id" = "res_partner__commercial_partner_id"."id")
WHERE "res_partner"."active" IS TRUE AND (
("res_partner"."commercial_partner_id" IS NOT NULL AND "res_partner__commercial_partner_id"."name" IN %s)
AND EXISTS(SELECT FROM (
SELECT "res_users"."partner_id" AS __inverse
FROM "res_users"
WHERE "res_users"."login" LIKE %s
) AS __sub WHERE __inverse = "res_partner"."id")
)
ORDER BY "res_partner"."complete_name" ASC, "res_partner"."id" DESC
SELECT "res_partner_category"."id"
FROM "res_partner_category"
LEFT JOIN "res_partner_category" AS "res_partner_category__parent_id" ON (
"res_partner_category"."parent_id" = "res_partner_category__parent_id"."id")
WHERE ("res_partner_category"."active" IS TRUE AND "res_partner_category"."id" IN %s)
AND (NOT EXISTS(
SELECT FROM (
SELECT "res_partner_category"."parent_id" AS __inverse
FROM "res_partner_category"
WHERE
(
"res_partner_category"."name" ->> %s ILIKE %s
AND "res_partner_category"."parent_id" IS NOT NULL
)
) AS __sub
WHERE __inverse = "res_partner_category"."id"
)
AND (
"res_partner_category"."parent_id" IS NOT NULL
AND "res_partner_category__parent_id"."name" ->> %s ILIKE %s
)
)
ORDER BY "res_partner_category"."name" ->> %s, "res_partner_category"."id"
''']):
Model.search([])
records_search = PartnerCateg.search(domain)
self.assertEqual(records_search, accessible_records)
self.assertEqual(
records.with_user(self.env.ref('base.user_admin'))._filtered_access('read'),
accessible_records,
)
def test_access_rules_active_test_neg(self):
Model = self.env['res.partner'].with_user(self.env.ref('base.user_admin'))

View file

@ -221,9 +221,10 @@ class TestRequestRemainingAfterFirstCheck(TestRequestRemainingCommon):
s.get(self.base_url() + "/web/concurrent", timeout=10)
type(self).thread_a = threading.Thread(target=late_request_thread)
main_lock = self.main_lock
self.thread_a.start()
# we need to ensure that the first check is made and that we are aquiring the lock
self.main_lock.acquire()
main_lock.acquire()
def assertCanOpenTestCursor(self):
super().assertCanOpenTestCursor()

View file

@ -70,6 +70,28 @@ class TestIntervals(TransactionCase):
[(0, 5), (12, 13), (20, 22), (23, 24)],
)
def test_keep_distinct(self):
""" Test merge operations between two Intervals
instances with different _keep_distinct flags.
"""
A = Intervals(self.ints([(0, 10)]), keep_distinct=False)
B = Intervals(self.ints([(-5, 5), (5, 15)]), keep_distinct=True)
C = A & B
# The _keep_distinct flag must be the same as the left one
self.assertFalse(C._keep_distinct)
self.assertEqual(len(C), 1)
self.assertEqual(list(C), self.ints([(0, 10)]))
# If, as a result of the above operation, C has _keep_distinct = False
# but is not preserving its _items, the following operation must raise
# an error
D = Intervals()
C = C - D
self.assertFalse(C._keep_distinct)
self.assertEqual(C._items, self.ints([(0, 10)]))
class TestUtils(TransactionCase):

View file

@ -0,0 +1,77 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase, tagged
from odoo.tools import convert_file
from odoo.tools.misc import file_path
@tagged('-at_install', 'post_install')
class TestAsset(TransactionCase):
def test_asset_tag(self):
"""
Verify that assets defined with the <asset> tag are properly imported.
"""
# Load new records
convert_file(
self.env, 'base',
file_path('base/tests/asset_tag.xml'),
{}, 'init', False,
)
active_keep_asset = self.env.ref('base.test_asset_tag_aaa')
inactive_keep_asset = self.env.ref('base.test_asset_tag_iii')
active_switch_asset_reset = self.env.ref('base.test_asset_tag_aia')
active_switch_asset_ignore = self.env.ref('base.test_asset_tag_aii')
inactive_switch_asset = self.env.ref('base.test_asset_tag_iaa')
prepend_asset = self.env.ref('base.test_asset_tag_prepend')
asset_with_extra_field = self.env.ref('base.test_asset_tag_extra')
# Verify initial load
self.assertEqual(prepend_asset._name, 'ir.asset', 'Model should be ir.asset')
self.assertEqual(prepend_asset.name, 'Test asset tag with directive', 'Name not loaded')
self.assertEqual(prepend_asset.directive, 'prepend', 'Directive not loaded')
self.assertEqual(prepend_asset.bundle, 'test_asset_bundle', 'Bundle not loaded')
self.assertEqual(prepend_asset.path, 'base/tests/something.scss', 'Path not loaded')
self.assertEqual(asset_with_extra_field.sequence, 17, 'Sequence not loaded')
self.assertTrue(active_keep_asset.active, 'Should be active')
self.assertTrue(active_switch_asset_reset.active, 'Should be active')
self.assertTrue(active_switch_asset_ignore.active, 'Should be active')
self.assertFalse(inactive_keep_asset.active, 'Should be inactive')
self.assertFalse(inactive_switch_asset.active, 'Should be inactive')
# Patch records
prepend_asset.name = 'changed'
prepend_asset.directive = 'append'
prepend_asset.bundle = 'changed'
prepend_asset.path = 'base/tests/changed.scss'
asset_with_extra_field.sequence = 3
active_switch_asset_reset.active = False
active_switch_asset_ignore.active = False
inactive_switch_asset.active = True
# Update records
convert_file(
self.env, 'base',
file_path('base/tests/asset_tag.xml'),
{
'base.test_asset_tag_aaa': active_keep_asset.id,
'base.test_asset_tag_iii': inactive_keep_asset.id,
'base.test_asset_tag_aia': active_switch_asset_reset.id,
'base.test_asset_tag_aii': active_switch_asset_ignore.id,
'base.test_asset_tag_iaa': inactive_switch_asset.id,
'base.test_asset_tag_prepend': prepend_asset.id,
'base.test_asset_tag_extra': asset_with_extra_field.id,
}, 'update', False,
)
# Verify updated load
self.assertEqual(prepend_asset.name, 'Test asset tag with directive', 'Name not restored')
self.assertEqual(prepend_asset.directive, 'prepend', 'Directive not restored')
self.assertEqual(prepend_asset.bundle, 'test_asset_bundle', 'Bundle not restored')
self.assertEqual(prepend_asset.path, 'base/tests/something.scss', 'Path not restored')
self.assertEqual(asset_with_extra_field.sequence, 17, 'Sequence not restored')
self.assertTrue(active_keep_asset.active, 'Should be active')
self.assertTrue(active_switch_asset_reset.active, 'Should be reset to active')
self.assertFalse(active_switch_asset_ignore.active, 'Should be kept inactive')
self.assertFalse(inactive_keep_asset.active, 'Should be inactive')
self.assertTrue(inactive_switch_asset.active, 'Should be kept active')

View file

@ -114,6 +114,21 @@ class TestIrCron(TransactionCase, CronMixinCase):
self.assertEqual(self.cron.lastcall, fields.Datetime.now())
self.assertEqual(self.partner.name, 'You have been CRONWNED')
def test_cron_direct_trigger_exception(self):
self.cron.code = textwrap.dedent("raise UserError('oops')")
with (
self.enter_registry_test_mode(),
self.assertLogs('odoo.addons.base.models.ir_cron', 40), # logging.ERROR
self.registry.cursor() as cron_cr,
):
action = self.cron.with_env(self.env(cr=cron_cr)).method_direct_trigger()
self.assertNotEqual(action, True)
action_params = action.pop('params')
self.assertEqual(action, {'type': 'ir.actions.client', 'tag': 'display_exception'})
self.assertEqual(list(action_params), ['code', 'message', 'data'])
self.assertEqual(list(action_params['data']), ['name', 'message', 'arguments', 'context', 'debug'])
def test_cron_no_job_ready(self):
self.cron.nextcall = fields.Datetime.now() + timedelta(days=1)
self.cron.flush_recordset()

View file

@ -475,7 +475,7 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase):
)
def test_eml_attachment_encoding(self):
"""Test that message/rfc822 attachments are encoded using 7bit, 8bit, or binary encoding."""
"""Test that message/rfc822 attachments are encoded using 7bit, 8bit, or binary encoding per RFC."""
IrMailServer = self.env['ir.mail_server']
# Create a sample .eml file content
@ -491,12 +491,43 @@ class TestIrMailServer(TransactionCase, MockSmtplibCase):
attachments=attachments,
)
# Verify that the attachment is correctly encoded
acceptable_encodings = {'7bit', '8bit', 'binary'}
found_rfc822_part = False
for part in message.iter_attachments():
if part.get_content_type() == 'message/rfc822':
found_rfc822_part = True
# Get Content-Transfer-Encoding, defaulting to '7bit' if not present (per RFC)
encoding = part.get('Content-Transfer-Encoding', '7bit').lower()
self.assertIn(
part.get('Content-Transfer-Encoding'),
encoding,
acceptable_encodings,
"The message/rfc822 attachment should be encoded using 7bit, 8bit, or binary encoding.",
f"RFC violation: message/rfc822 attachment has Content-Transfer-Encoding '{encoding}'. "
f"Only 7bit, 8bit, or binary encoding is permitted per RFC 2046 Section 5.2.1."
)
self.assertTrue(found_rfc822_part, "No message/rfc822 attachment found in the built email")
def test_eml_message_serialization_with_non_ascii(self):
"""Ensure an email with a message/rfc822 attachment containing non-ASCII chars can be serialized."""
IrMailServer = self.env['ir.mail_server']
# .eml content with non-ASCII character
eml_content = "From: user@example.com\nTo: user2@example.com\nSubject: Test\n\nBody with é"
attachments = [('test.eml', eml_content.encode(), 'message/rfc822')]
message = IrMailServer._build_email__(
email_from='john.doe@from.example.com',
email_to='destinataire@to.example.com',
subject='Serialization test',
body='This email contains a .eml attachment.',
attachments=attachments,
)
try:
serialized = message.as_string().encode('utf-8')
except UnicodeEncodeError as e:
raise AssertionError("Email with non-ASCII .eml attachment could not be serialized") from e
self.assertIsInstance(serialized, bytes)

View file

@ -27,6 +27,7 @@ except ImportError:
aiosmtpd = None
SMTP_TIMEOUT = 5
PASSWORD = 'secretpassword'
_openssl = shutil.which('openssl')
_logger = logging.getLogger(__name__)
@ -68,7 +69,7 @@ class Certificate:
@unittest.skipUnless(aiosmtpd, "aiosmtpd couldn't be imported")
@unittest.skipUnless(_openssl, "openssl not found in path")
# fail fast for timeout errors
@patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', .1)
@patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', SMTP_TIMEOUT)
# prevent the CLI from interfering with the tests
@patch.dict(config.options, {'smtp_server': ''})
class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
@ -146,8 +147,8 @@ class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
# when resolving "localhost" (so stupid), use the following to
# force aiosmtpd/odoo to bind/connect to a fixed ipv4 OR ipv6
# address.
family, _, cls.port = _find_free_local_address()
cls.localhost = getaddrinfo('localhost', cls.port, family)
family, addr, cls.port = _find_free_local_address()
cls.localhost = getaddrinfo(addr, cls.port, family)
cls.startClassPatcher(patch('socket.getaddrinfo', cls.getaddrinfo))
def setUp(self):
@ -268,13 +269,14 @@ class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
'smtp_ssl_private_key': private_key,
})
if error_pattern:
with self.assertRaises(UserError) as error_capture:
timeout = .1 if 'timed out' in error_pattern else SMTP_TIMEOUT
with self.assertRaises(UserError) as error_capture, \
patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', timeout):
mail_server.test_smtp_connection()
self.assertRegex(error_capture.exception.args[0], error_pattern)
else:
mail_server.test_smtp_connection()
def test_authentication_login_matrix(self):
"""
Connect to a server that is authenticating users via a login/pwd
@ -318,7 +320,9 @@ class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
password=password):
with self.start_smtpd(encryption, ssl_context, auth_required):
if error_pattern:
with self.assertRaises(UserError) as capture:
timeout = .1 if 'timed out' in error_pattern else SMTP_TIMEOUT
with self.assertRaises(UserError) as capture, \
patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', timeout):
mail_server.test_smtp_connection()
self.assertRegex(capture.exception.args[0], error_pattern)
else:
@ -374,7 +378,9 @@ class TestIrMailServerSMTPD(TransactionCaseWithUserDemo):
client_encryption=client_encryption):
mail_server.smtp_encryption = client_encryption
with self.start_smtpd(server_encryption, ssl_context, auth_required=False):
with self.assertRaises(UserError) as capture:
timeout = .1 if 'timed out' in error_pattern else SMTP_TIMEOUT
with self.assertRaises(UserError) as capture, \
patch('odoo.addons.base.models.ir_mail_server.SMTP_TIMEOUT', timeout):
mail_server.test_smtp_connection()
self.assertRegex(capture.exception.args[0], error_pattern)

View file

@ -221,7 +221,7 @@ class TestIrSequenceGenerate(BaseCase):
isoyear, isoweek, weekday = datetime.now().isocalendar()
self.assertEqual(
env['ir.sequence'].next_by_code('test_sequence_type_9'),
f"{isoyear}/{isoyear % 100}/1/{isoweek}/{weekday % 7}",
f"{isoyear}/{isoyear % 100:02d}/1/{isoweek:02d}/{weekday % 7}",
)
def test_ir_sequence_suffix(self):

View file

@ -402,13 +402,15 @@ class TestHtmlTools(BaseCase):
def test_plaintext2html(self):
cases = [
("First \nSecond \nThird\n \nParagraph\n\r--\nSignature paragraph", 'div',
("First \nSecond \nThird\n \nParagraph\n\r--\nSignature paragraph", 'div', True,
"<div><p>First <br/>Second <br/>Third</p><p>Paragraph</p><p>--<br/>Signature paragraph</p></div>"),
("First<p>It should be escaped</p>\nSignature", False,
"<p>First&lt;p&gt;It should be escaped&lt;/p&gt;<br/>Signature</p>")
("First<p>It should be escaped</p>\nSignature", False, True,
"<p>First&lt;p&gt;It should be escaped&lt;/p&gt;<br/>Signature</p>"),
("First \nSecond \nThird", False, False,
"First <br/>Second <br/>Third"),
]
for content, container_tag, expected in cases:
html = plaintext2html(content, container_tag)
for content, container_tag, with_paragraph, expected in cases:
html = plaintext2html(content, container_tag, with_paragraph)
self.assertEqual(html, expected, 'plaintext2html is broken')
def test_html_html_to_inner_content(self):

View file

@ -94,7 +94,7 @@ class TestModuleManifest(BaseCase):
file.write(str({'name': f'Temp {self.module_name}'}))
with self.assertLogs('odoo.modules.module', 'WARNING') as capture:
manifest = Manifest.for_addon(self.module_name)
manifest.manifest_cached
manifest.raw_value('') # parse the manifest
self.assertEqual(manifest['license'], 'LGPL-3')
self.assertEqual(manifest['author'], '')
self.assertIn("Missing `author` key", capture.output[0])

View file

@ -58,6 +58,43 @@ class TestQwebFieldInteger(common.TransactionCase):
"125.125k"
)
class TestQwebFieldFloatConverter(common.TransactionCase):
def value_to_html(self, value, options=None):
options = options or {}
return self.env['ir.qweb.field.float'].value_to_html(value, options)
def test_float_value_to_html_no_precision(self):
self.assertEqual(self.value_to_html(3), '3.0')
self.assertEqual(self.value_to_html(3.1), '3.1')
self.assertEqual(self.value_to_html(3.1231239), '3.123124')
def test_float_value_to_html_with_precision(self):
options = {'precision': 3}
self.assertEqual(self.value_to_html(3, options), '3.000')
self.assertEqual(self.value_to_html(3.1, options), '3.100')
self.assertEqual(self.value_to_html(3.123, options), '3.123')
self.assertEqual(self.value_to_html(3.1239, options), '3.124')
def test_float_value_to_html_with_min_precision(self):
options = {'min_precision': 3}
self.assertEqual(self.value_to_html(0, options), '0.000')
self.assertEqual(self.value_to_html(3, options), '3.000')
self.assertEqual(self.value_to_html(3.1, options), '3.100')
self.assertEqual(self.value_to_html(3.123, options), '3.123')
self.assertEqual(self.value_to_html(3.1239, options), '3.1239')
self.assertEqual(self.value_to_html(3.1231239, options), '3.123124')
self.assertEqual(self.value_to_html(1234567890.1234567890, options), '1,234,567,890.12346')
def test_float_value_to_html_with_precision_and_min_precision(self):
options = {'min_precision': 3, 'precision': 4}
self.assertEqual(self.value_to_html(3, options), '3.000')
self.assertEqual(self.value_to_html(3.1, options), '3.100')
self.assertEqual(self.value_to_html(3.123, options), '3.123')
self.assertEqual(self.value_to_html(3.1239, options), '3.1239')
self.assertEqual(self.value_to_html(3.12349, options), '3.1235')
class TestQwebFieldContact(common.TransactionCase):
@classmethod
def setUpClass(cls):
@ -86,3 +123,53 @@ class TestQwebFieldContact(common.TransactionCase):
self.assertIn(self.partner.website, result)
self.assertNotIn(self.partner.phone, result)
self.assertIn('itemprop="telephone"', result, "Empty telephone itemprop should be added to prevent issue with iOS Safari")
class TestQwebFieldOne2Many(common.TransactionCase):
def value_to_html(self, value, options=None):
options = options or {}
return self.env['ir.qweb.field.one2many'].value_to_html(value, options)
def test_one2many_empty(self):
partner = self.env['res.partner'].create({'name': 'Test Parent'})
self.assertFalse(self.value_to_html(partner.child_ids))
def test_one2many_with_values(self):
parent = self.env['res.partner'].create({'name': 'Parent'})
self.env['res.partner'].create({'name': 'Child', 'parent_id': parent.id})
self.assertEqual(self.value_to_html(parent.child_ids), "Parent, Child")
class TestQwebFieldMany2Many(common.TransactionCase):
def value_to_html(self, value, options=None):
options = options or {}
return self.env['ir.qweb.field.many2many'].value_to_html(value, options)
def test_many2many_empty(self):
user = self.env['res.users'].create({'name': 'UserTest', 'login': 'usertest@example.com', 'group_ids': None})
self.assertFalse(self.value_to_html(user.group_ids))
def test_many2many_with_values(self):
user = self.env['res.users'].create({
'name': 'User2',
'login': 'user2@example.com',
})
self.assertEqual(
self.value_to_html(user.all_group_ids[:2].sorted()),
'Role / User, Technical Features',
)
class TestQwebFieldMany2One(common.TransactionCase):
def value_to_html(self, value, options=None):
options = options or {}
return self.env['ir.qweb.field.many2one'].value_to_html(value, options)
def test_many2one_empty(self):
partner = self.env['res.partner'].create({'name': 'Lonely'})
self.assertFalse(self.value_to_html(partner.parent_id))
def test_many2one_with_value(self):
parent = self.env['res.partner'].create({'name': 'BigBoss'})
child = self.env['res.partner'].create({'name': 'Minion', 'parent_id': parent.id})
self.assertEqual(self.value_to_html(child.parent_id), 'BigBoss')

View file

@ -30,6 +30,7 @@ class TestReports(odoo.tests.TransactionCase):
'account.report_original_vendor_bill': [('move_type', 'in', ('in_invoice', 'in_receipt'))],
'account.report_invoice_with_payments': invoice_domain,
'account.report_invoice': invoice_domain,
'account_edi_ubl_cii.account_invoices_generated_by_odoo': [('move_type', 'in', ('in_invoice', 'in_refund'))],
'l10n_th.report_commercial_invoice': invoice_domain,
}
extra_data_reports = {
@ -561,6 +562,46 @@ class TestReportsRendering(TestReportsRenderingCommon):
pages_contents = [[elem[1] for elem in page] for page in pages]
self.assertEqual(pages_contents, expected_pages_contents)
def test_report_specific_paperformat_args(self):
"""
Verify that the values defined in `specific_paperformat_args` take
precedence over those in the paperformat when building the wkhtmltopdf
command arguments.
"""
command_args = self.env['ir.actions.report']._build_wkhtmltopdf_args(
self.env['report.paperformat'].new({
'format': 'A4',
'margin_top': 25,
'margin_left': 50,
'margin_bottom': 75,
'margin_right': 100,
'dpi': 90,
'header_spacing': 125,
'orientation': 'portrait'
}),
landscape=None,
specific_paperformat_args={
'data-report-landscape': True,
'data-report-margin-top': 0,
'data-report-margin-bottom': 0,
'data-report-header-spacing': 0,
'data-report-dpi': 96
})
self.assertEqual(command_args, [
'--disable-local-file-access',
'--quiet',
'--page-size', 'A4',
'--margin-top', '0',
'--dpi', '96',
'--zoom', '1.0',
'--header-spacing', '0',
'--margin-left', '50.0',
'--margin-bottom', '0',
'--margin-right', '100.0',
'--javascript-delay', '1000',
'--orientation', 'landscape',
])
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'pdf_rendering')
class TestReportsRenderingLimitations(TestReportsRenderingCommon):

View file

@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import patch
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase
from odoo.tests.common import Command, TransactionCase
class TestCompany(TransactionCase):
@ -54,3 +56,13 @@ class TestCompany(TransactionCase):
def test_create_branch_with_default_parent_id(self):
branch = self.env['res.company'].with_context(default_parent_id=self.env.company.id).create({'name': 'Branch Company'})
self.assertFalse(branch.partner_id.parent_id)
def test_write_company_root_delegated_field_names(self):
self.env['res.company'].with_context(default_parent_id=self.env.company.id).create({'name': 'foo'})
new_currency = self.env['res.currency'].create({
'name': 'AAA',
'symbol': 'AAA',
'rate_ids': [Command.create({'name': '2009-09-09', 'rate': 1})]
})
with patch('odoo.addons.base.models.res_company.ResCompany._get_company_root_delegated_field_names', return_value=["currency_id", "zip"]):
self.env.company.write({'currency_id': new_currency.id, 'zip': '12345'})

View file

@ -492,6 +492,13 @@ class TestPartnerAddressCompany(TransactionCase):
self.assertFalse(ct1_1.vat)
self.assertEqual(inv_1.street, 'Invoice Child Street', 'Should take parent address')
self.assertFalse(inv_1.vat)
# test it also works with default_parent_id value in context
# also ensure it works directly on a non-empty recordset
inv_2 = (ct1_1 | inv_1).with_context(default_parent_id=inv.id).create({
'name': 'Address, Child of Invoice',
})
self.assertEqual(inv_2.street, 'Invoice Child Street', 'Should take parent address')
self.assertFalse(inv_2.vat)
# sync P1 with parent, check address is update + other fields in write kept
ct1_phone = '+320455999999'
@ -1105,8 +1112,7 @@ class TestPartnerForm(TransactionCase):
def test_lang_computation_form_view(self):
""" Check computation of lang: coming from installed languages, forced
default value and propagation from parent."""
default_lang_info = self.env['res.lang'].get_installed()[0]
default_lang_code = default_lang_info[0]
default_lang_code = self.env['ir.default']._get('res.partner', 'lang') or False
self.assertNotEqual(default_lang_code, 'de_DE') # should not be the case, just to ease test
self.assertNotEqual(default_lang_code, 'fr_FR') # should not be the case, just to ease test

View file

@ -31,6 +31,7 @@ class TestResPartnerBank(SavepointCaseWithUserDemo):
# sanitaze the acc_number
sanitized_acc_number = 'BE001251882303'
self.assertEqual(partner_bank.sanitized_acc_number, sanitized_acc_number)
vals = partner_bank_model.search(
[('acc_number', '=', sanitized_acc_number)])
self.assertEqual(1, len(vals))
@ -49,3 +50,111 @@ class TestResPartnerBank(SavepointCaseWithUserDemo):
vals = partner_bank_model.search(
[('acc_number', '=', acc_number.lower())])
self.assertEqual(1, len(vals))
# updating the sanitized value will also update the acc_number
partner_bank.write({'sanitized_acc_number': 'BE001251882303WRONG'})
self.assertEqual(partner_bank.acc_number, partner_bank.sanitized_acc_number)
def test_find_or_create_bank_account_create(self):
partner = self.env['res.partner'].create({'name': 'partner name'})
found_bank = self.env['res.partner.bank']._find_or_create_bank_account(
account_number='account number',
partner=partner,
company=self.env.company,
)
# The bank didn't exist, we should create it
self.assertRecordValues(found_bank, [{
'acc_number': 'account number',
'partner_id': partner.id,
'company_id': False,
'active': True,
}])
def test_find_or_create_bank_account_find_active(self):
partner = self.env['res.partner'].create({'name': 'partner name'})
bank = self.env['res.partner.bank'].create({
'acc_number': 'account number',
'partner_id': partner.id,
'company_id': False,
'active': True,
})
found_bank = self.env['res.partner.bank']._find_or_create_bank_account(
account_number='account number',
partner=partner,
company=self.env.company,
)
# The bank exists and is active, we should not create a new one
self.assertEqual(bank, found_bank)
def test_find_or_create_bank_account_find_inactive(self):
partner = self.env['res.partner'].create({'name': 'partner name'})
self.env['res.partner.bank'].create({
'acc_number': 'account number',
'partner_id': partner.id,
'company_id': False,
'active': False,
})
found_bank = self.env['res.partner.bank']._find_or_create_bank_account(
account_number='account number',
partner=partner,
company=self.env.company,
)
# The bank exists but is inactive, we should neither create a new one, neither return it
self.assertFalse(found_bank)
def test_find_or_create_bank_account_find_parent(self):
partner = self.env['res.partner'].create({'name': 'partner name'})
contact = self.env['res.partner'].create({'name': 'contact', 'parent_id': partner.id})
partner_bank = self.env['res.partner.bank'].create({
'acc_number': 'account number',
'partner_id': partner.id,
})
# Only the bank on the commercial partner exists
found_bank = self.env['res.partner.bank']._find_or_create_bank_account(
account_number='account number',
partner=partner,
company=self.env.company,
)
self.assertEqual(partner_bank, found_bank)
found_bank = self.env['res.partner.bank']._find_or_create_bank_account(
account_number='account number',
partner=contact,
company=self.env.company,
)
self.assertEqual(partner_bank, found_bank)
# Now the bank exists on both partners
contact_bank = self.env['res.partner.bank'].create({
'acc_number': 'account number',
'partner_id': contact.id,
})
found_bank = self.env['res.partner.bank']._find_or_create_bank_account(
account_number='account number',
partner=partner,
company=self.env.company,
)
self.assertEqual(partner_bank, found_bank)
found_bank = self.env['res.partner.bank']._find_or_create_bank_account(
account_number='account number',
partner=contact,
company=self.env.company,
)
self.assertEqual(contact_bank, found_bank)
# Only the bank on the contact exists
partner_bank.unlink()
found_bank = self.env['res.partner.bank']._find_or_create_bank_account(
account_number='account number',
partner=partner,
company=self.env.company,
)
self.assertEqual(contact_bank, found_bank)
found_bank = self.env['res.partner.bank']._find_or_create_bank_account(
account_number='account number',
partner=contact,
company=self.env.company,
)
self.assertEqual(contact_bank, found_bank)

View file

@ -1,5 +1,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import patch
@ -221,6 +222,19 @@ class TestUsers(UsersCommonCase):
self.assertTrue(portal_partner_2.exists(), 'Should have kept the partner')
self.assertEqual(asked_deletion_2.state, 'fail', 'Should have marked the deletion as failed')
def test_delete_public_user(self):
"""Test that the public user cannot be deleted."""
public_user = self.env.ref('base.public_user')
public_partner = public_user.partner_id
# Attempt to delete the public user
with self.assertRaises(UserError, msg="Public user should not be deletable"):
public_user.unlink()
# Ensure the public user still exists and is inactive
self.assertTrue(public_user.exists() and not public_user.active, "Public user should still exist and be inactive")
self.assertTrue(public_partner.exists() and not public_partner.active, "Public partner should still exist and be inactive")
def test_user_home_action_restriction(self):
test_user = new_test_user(self.env, 'hello world')
@ -428,6 +442,28 @@ class TestUsers2(UsersCommonCase):
with self.assertRaises(ValidationError, msg="The user cannot be at the same time in groups: ['Membre', 'Portal', 'Foo / Small user group']"):
user_form.save()
def test_view_group_hierarchy(self):
"""Test that the group hierarchy shows up in the correct language of the user."""
self.env['res.lang']._activate_lang('fr_FR')
group_system = self.env.ref('base.group_system')
group_system.with_context(lang='fr_FR').name = 'Administrateur'
view_group_hierarchy_en = self.env['res.groups']._get_view_group_hierarchy()
view_group_hierarchy_fr = self.env['res.groups'].with_context(lang='fr_FR')._get_view_group_hierarchy()
self.assertNotEqual(view_group_hierarchy_en['groups'][group_system.id]['name'], 'Administrateur')
self.assertEqual(view_group_hierarchy_fr['groups'][group_system.id]['name'], 'Administrateur')
# Should work the other way around too
self.env.registry.clear_cache('groups')
view_group_hierarchy_fr = self.env['res.groups'].with_context(lang='fr_FR')._get_view_group_hierarchy()
view_group_hierarchy_en = self.env['res.groups']._get_view_group_hierarchy()
self.assertNotEqual(view_group_hierarchy_en['groups'][group_system.id]['name'], 'Administrateur')
self.assertEqual(view_group_hierarchy_fr['groups'][group_system.id]['name'], 'Administrateur')
with patch('odoo.addons.base.models.res_groups.ResGroups._get_view_group_hierarchy') as mock:
self.user_portal_1.copy_data()
self.assertFalse(mock.called)
@users('portal_1')
@mute_logger('odoo.addons.base.models.ir_model')
def test_self_writeable_fields(self):
@ -495,6 +531,94 @@ class TestUsers2(UsersCommonCase):
"group_ids": [Command.link(contact_creation_group.id)],
})
def test_portal_user_manager_access(self):
# groups
group_portal = self.env.ref('base.group_portal')
group_user = self.env.ref('base.group_user')
group_partner_manager = self.env.ref('base.group_partner_manager')
group_portal_user_manager = self.env['res.groups'].create({
'name': 'Portal User Manager',
'user_ids': [],
})
# ACL
self.env['ir.model.access'].create({
'name': 'Allow user profile update',
'model_id': self.env['ir.model']._get('res.users').id,
'group_id': group_portal_user_manager.id,
'perm_write': True,
})
# Rules
self.env['ir.rule'].create({
'name': 'Allow updates by Portal Managers on PORTAL users (only)',
'model_id': self.env['ir.model']._get('res.users').id,
'groups': [group_portal_user_manager.id],
'domain_force': [('share', '=', True)],
'perm_write': True,
})
# Users
portal_user_manager = self.env['res.users'].create({
'name': 'Portal User Manager',
'login': 'maintainer',
'password': 'password',
'group_ids': [group_user.id, group_partner_manager.id, group_portal_user_manager.id],
})
user = self.env['res.users'].create({
'name': 'User',
'login': 'user_',
'password': 'password',
'group_ids': [group_user.id, group_partner_manager.id],
})
portal = self.env['res.users'].create({
'name': 'Portal',
'login': 'portal_',
'password': 'password',
'group_ids': [group_portal.id],
})
# A UPM cannot update the user profile of another USER
with self.assertRaises(AccessError):
user.with_user(portal_user_manager).write({
'name': 'New name for you'
})
# A UPM can update the user profile of a PORTAL user
portal.with_user(portal_user_manager).write({
'name': 'New name for you'
})
# A UPM cannot update the partner profile of another USER
with self.assertRaises(AccessError):
user.partner_id.with_user(portal_user_manager).write({
'name': 'New name for you'
})
# A UPM can update the partner profile of a PORTAL user
portal.partner_id.with_user(portal_user_manager).write({
'name': 'New name for you'
})
# A USER cannot update the user profile of another USER
with self.assertRaises(AccessError):
self.user_internal.with_user(user).write({
'name': 'New name for you'
})
# A USER cannot update the user profile of a PORTAL user
with self.assertRaises(AccessError):
portal.with_user(user).write({
'name': 'New name for you'
})
# A USER cannot update the partner profile of another USER
with self.assertRaises(AccessError):
self.user_internal.partner_id.with_user(user).write({
'name': 'New name for you'
})
# A USER can update the partner profile of a PORTAL user
portal.partner_id.with_user(user).write({
'name': 'New name for you'
})
class TestUsersTweaks(TransactionCase):
def test_superuser(self):
@ -517,10 +641,10 @@ class TestUsersIdentitycheck(HttpCase):
self.env.user.password = "admin@odoo"
# Create a first session that will be used to revoke other sessions
session = self.authenticate('admin', 'admin@odoo')
session = self.authenticate('admin', 'admin@odoo', session_extra={'_trace_disable': False})
# Create a second session that will be used to check it has been revoked
self.authenticate('admin', 'admin@odoo')
self.authenticate('admin', 'admin@odoo', session_extra={'_trace_disable': False})
# Test the session is valid
# Valid session -> not redirected from /web to /web/login
self.assertTrue(self.url_open('/web').url.endswith('/web'))
@ -545,3 +669,77 @@ class TestUsersIdentitycheck(HttpCase):
# In addition, the password must have been emptied from the wizard
self.assertFalse(user_identity_check.password)
@tagged('post_install', '-at_install')
class TestApiKeys(UsersCommonCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env['ir.config_parameter'].set_param('base.enable_programmatic_api_keys', 1)
UsersApiKeys = cls.env['res.users.apikeys'].with_user(cls.user_internal)
cls.tomorrow = datetime.now() + timedelta(days=1)
cls.unscoped_key = UsersApiKeys._generate(None, 'Key without a scope', cls.tomorrow)
cls.scoped_key = UsersApiKeys._generate('scope', 'Key with a scope', cls.tomorrow)
def test_programmatic_apikey_management_is_deactivated_by_default(self):
self.env['ir.config_parameter'].set_param('base.enable_programmatic_api_keys', None)
# Attempting to create a key raises an error
with self.assertRaisesRegex(UserError, 'Programmatic API keys are not enabled'):
self.env['res.users.apikeys'].with_user(self.user_internal).generate(
self.unscoped_key, None, 'Another key without a scope', self.tomorrow)
# Attempting to revoke a key raises an error
with self.assertRaisesRegex(UserError, 'Programmatic API keys are not enabled'):
self.env['res.users.apikeys'].with_user(self.user_internal).revoke(self.unscoped_key)
def test_generate_apikey_is_limited(self):
# create 8 new keys, which makes 10 keys in total for user_internal
for i in range(8):
self.env['res.users.apikeys'].with_user(self.user_internal).generate(
self.unscoped_key, None, 'Another key without a scope', self.tomorrow)
with self.assertRaisesRegex(UserError, 'Limit of 10 API keys is reached'):
self.env['res.users.apikeys'].with_user(self.user_internal).generate(
self.unscoped_key, None, 'Another key without a scope', self.tomorrow)
# This ICP can change the limit
self.env['ir.config_parameter'].set_param('base.programmatic_api_keys_limit', 11)
self.env['res.users.apikeys'].with_user(self.user_internal).generate(
self.unscoped_key, None, 'Another key without a scope', self.tomorrow)
def test_generate_apikey_raises_when_creating_unscoped_key_from_scoped_key(self):
# Creating an unscoped key from a scoped key raises an error
with self.assertRaisesRegex(UserError, 'The provided API key is invalid or does not belong to the current user'):
self.env['res.users.apikeys'].with_user(self.user_internal).generate(
self.scoped_key, None, 'Another key without a scope', self.tomorrow)
def test_generate_apikey_raises_when_creating_key_from_differently_scoped_key(self):
# Creating a key with a different scope raises an error
with self.assertRaisesRegex(UserError, 'The provided API key is invalid or does not belong to the current user'):
self.env['res.users.apikeys'].with_user(self.user_internal).generate(
self.scoped_key, 'other', 'Another key with another scope', self.tomorrow)
def test_generate_apikey_accepts_creating_key_from_identically_scoped_key(self):
# Creating a key with the same scope doesn't raise
self.env['res.users.apikeys'].with_user(self.user_internal).generate(
self.scoped_key, 'scope', 'Another key with a scope', self.tomorrow)
def test_generate_apikey_accepts_creating_scoped_key_from_unscoped_key(self):
# Creating a key with a scope from an unscoped key doesn't raise
self.env['res.users.apikeys'].with_user(self.user_internal).generate(
self.unscoped_key, 'scope', 'Another key with a scope', self.tomorrow)
def test_generate_apikey_accepts_creating_unscoped_key_from_unscoped_key(self):
# Creating an unscoped key from another unscoped key doesn't raise
self.env['res.users.apikeys'].with_user(self.user_internal).generate(
self.unscoped_key, None, 'Another key without a scope', self.tomorrow)
def test_generate_apikey_checks_ownership(self):
# Check that an API key cannot be generated from another user's API key
with self.assertRaisesRegex(UserError, 'The provided API key is invalid or does not belong to the current user'):
self.env['res.users.apikeys'].with_user(SUPERUSER_ID).generate(
self.unscoped_key, None, 'Another key without a scope', self.tomorrow)

View file

@ -49,6 +49,28 @@ class TestRetry(TestRetryCommon):
self.assertEqual(tests_run_count, self.count)
@tagged('test_retry', 'test_retry_success')
class TestRetryTraceback(TestRetryCommon):
""" Check some tests behaviour when ODOO_TEST_FAILURE_RETRIES is set"""
def test_retry_traceback_success(self):
tests_run_count = self.get_tests_run_count()
self.update_count()
if tests_run_count != self.count:
_logger.error('Traceback (most recent call last):\n')
self.assertEqual(tests_run_count, self.count)
@tagged('test_retry', 'test_retry_success')
class TestRetryTracebackArg(TestRetryCommon):
def test_retry_traceback_args_success(self):
tests_run_count = self.get_tests_run_count()
self.update_count()
if tests_run_count != self.count:
_logger.error('%s', 'Traceback (most recent call last):\n')
self.assertEqual(tests_run_count, self.count)
@tagged('-standard', 'test_retry', 'test_retry_failures')
class TestRetryFailures(TestRetryCommon):
def test_retry_failure_assert(self):

View file

@ -153,6 +153,74 @@ class TranslationToolsTestCase(BaseCase):
self.assertEqual(result, source)
self.assertItemsEqual(terms, ['Form stuff'])
def test_translate_xml_o_translate_inline_on_block(self):
""" Test xml_translate() with non-inline elements with o_translate_inline. """
terms = []
source = """<div>
<h1 class="o_translate_inline">Blah</h1>more text
<h1 class="o_translate_inline" t-if="True">Other Blah</h1>even more text
</div>"""
result = xml_translate(terms.append, source)
self.assertEqual(result, source)
self.assertItemsEqual(terms,
['<h1 class="o_translate_inline">Blah</h1>more text', 'Other Blah', 'even more text'])
def test_translate_xml_o_translate_inline_on_parent(self):
""" Test xml_translate() with non-inline elements inside o_translate_inline. """
terms = []
source = """<div>
<span class="o_translate_inline">Blah<h1>more text</h1></span>
<span class="o_translate_inline">Other Blah<h1 t-if="True">even more text</h1></span>
</div>"""
result = xml_translate(terms.append, source)
self.assertEqual(result, source)
self.assertItemsEqual(terms,
['<span class="o_translate_inline">Blah<h1>more text</h1></span>', 'Other Blah', 'even more text'])
def test_translate_xml_highlight(self):
""" Test xml_translate() with highlight span (with o_translate_inline). """
terms = []
source = """<div>
<span class="o_text_highlight o_translate_inline">
<a>solo link</a>
</span>
</div>
<div>
<span class="o_text_highlight o_translate_inline">
<span>Here is a <a>nested link</a> in highlight</span>
</span>
</div>"""
result = xml_translate(terms.append, source)
self.assertEqual(result, source)
self.assertItemsEqual(terms, ["""<span class="o_text_highlight o_translate_inline">
<a>solo link</a>
</span>""", """<span class="o_text_highlight o_translate_inline">
<span>Here is a <a>nested link</a> in highlight</span>
</span>"""])
def test_translate_xml_o_translate_inline_with_groups(self):
""" Test xml_translate() with groups attribute and with o_translate_inline. """
terms = []
source = """<div>
<a class="o_translate_inline" href="#" groups="anyone">Skip</a>
</div>"""
result = xml_translate(terms.append, source)
self.assertEqual(result, source)
self.assertItemsEqual(terms, ['Skip'])
def test_translate_xml_groups(self):
""" Test xml_translate() with groups attributes. """
terms = []
source = """<t t-name="stuff">
stuff before
<span groups="anyone"/>
stuff after
</t>"""
result = xml_translate(terms.append, source)
self.assertEqual(result, source)
self.assertItemsEqual(terms,
['stuff before', 'stuff after'])
def test_translate_xml_t(self):
""" Test xml_translate() with t-* attributes. """
terms = []
@ -1521,6 +1589,29 @@ class TestXMLTranslation(TransactionCase):
f'arch_db for {lang} should be {archf2} when check_translations'
)
def test_t_call_no_normal_attribute_translation(self):
self.env['ir.ui.view'].create({
'type': 'qweb',
'key': 'test',
'arch': '<button><t t-out="placeholder"/></button>',
})
view0 = self.env['ir.ui.view'].with_context(lang='fr_FR', edit_translations=True).create({
'type': 'qweb',
'arch': '<t t-call="test" placeholder="hello"/>',
})
self.assertEqual(view0._render_template(view0.id, {'hello': 'world'}), '<button>world</button>')
self.assertEqual(view0.arch_db, '<t t-call="test" placeholder="hello"/>')
view0.arch = '<t t-call="test" placeholder.translate="hello"/>'
translate_node = (
f'<span data-oe-model="ir.ui.view" data-oe-id="{view0.id}"'
' data-oe-field="arch_db" data-oe-translation-state="to_translate"'
' data-oe-translation-source-sha="2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824">hello</span>'
)
self.assertEqual(view0._render_template(view0.id), f'<button>{translate_node}</button>')
translate_attr = translate_node.replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
self.assertEqual(view0.arch_db, f'<t t-call="test" placeholder.translate="{translate_attr}"/>')
def test_update_field_translations_source_lang(self):
""" call update_field_translations with source_lang """
archf = '<form string="%s"><div>%s</div><div>%s</div></form>'

View file

@ -13,7 +13,7 @@ from lxml.builder import E
from psycopg2 import IntegrityError
from psycopg2.extras import Json
from odoo.exceptions import AccessError, ValidationError
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.tests import common, tagged
from odoo.addons.base.tests.common import TransactionCaseWithUserDemo
from odoo.tools import mute_logger, view_validation, safe_eval
@ -1892,6 +1892,7 @@ class TestTemplating(ViewCase):
""")
self.assertEqual(arch, expected)
@tagged('post_install', '-at_install')
class TestViews(ViewCase):
@ -2229,6 +2230,25 @@ class TestViews(ViewCase):
'inherit_id': False,
})
def test_xml_editor_rejects_encoding_declaration(self):
"""Must raise a UserError when encoding declaration is included."""
with self.assertRaises(UserError):
self.View.create({
'name': 'encoding_declaration_view',
'arch_base': "<?xml version='1.0' encoding='utf-8'?>",
'inherit_id': False,
})
view = self.assertValid("<form string='Test'></form>", name="test_xml_encoding_view")
for field in ("arch", "arch_base"):
with self.subTest(field=field):
original_value = view[field]
with self.assertRaises(UserError):
view.write({field: "<?xml version='1.0' encoding='utf-8'?><form/>"})
self.assertXMLEqual(view[field], original_value)
def test_context_in_view(self):
arch = """
<form string="View">
@ -2695,6 +2715,28 @@ class TestViews(ViewCase):
self.assertValid(arch % 'base.group_no_one')
self.assertWarning(arch % 'base.dummy')
def test_groups_field_removed(self):
view = self.View.create({
'name': 'valid view',
'model': 'ir.ui.view',
'arch': """
<form string="View">
<span class="oe_inline" invisible="0 == 0">
(<field name="name" groups="base.group_portal"/>)
</span>
</form>
""",
})
arch = self.View.get_views([(view.id, view.type)])['views']['form']['arch']
self.assertEqual(arch, """
<form string="View">
<span class="oe_inline" invisible="0 == 0">
()
</span>
</form>
""".strip())
def test_attrs_groups_behavior(self):
view = self.View.create({
'name': 'foo',
@ -6090,3 +6132,20 @@ class ViewModifiers(ViewCase):
self.assertFalse(tree.xpath('//div[@id="foo"]'))
self.assertTrue(tree.xpath('//div[@id="bar"]'))
self.assertFalse(tree.xpath('//div[@id="stuff"]'))
def test_create_inherit_view_with_xpath_without_expr(self):
"""Test that creating inherited view containing <xpath> node without the 'expr' attribute."""
parent_view = self.env.ref('base.view_partner_form')
inherit_arch = """
<xpath position="replace">
<field name="name"/>
</xpath>
"""
with self.assertRaises(ValidationError):
self.env['ir.ui.view'].create({
'name': 'test.xpath.without.expr',
'inherit_id': parent_view.id,
'arch': inherit_arch,
})