devtools/testlib.py
brancholdstable
changeset 6665 90f2f20367bc
parent 6589 47cd31fd206b
child 6590 37b7f4df46b3
child 6720 43a38c093f6f
equal deleted inserted replaced
6018:f4d1d5d9ccbb 6665:90f2f20367bc
    22 __docformat__ = "restructuredtext en"
    22 __docformat__ = "restructuredtext en"
    23 
    23 
    24 import os
    24 import os
    25 import sys
    25 import sys
    26 import re
    26 import re
       
    27 import urlparse
       
    28 from os.path import dirname, join
    27 from urllib import unquote
    29 from urllib import unquote
    28 from math import log
    30 from math import log
    29 from contextlib import contextmanager
    31 from contextlib import contextmanager
    30 from warnings import warn
    32 from warnings import warn
    31 
    33 
    32 import yams.schema
    34 import yams.schema
    33 
    35 
    34 from logilab.common.testlib import TestCase, InnerTest
    36 from logilab.common.testlib import TestCase, InnerTest, Tags
    35 from logilab.common.pytest import nocoverage, pause_tracing, resume_tracing
    37 from logilab.common.pytest import nocoverage, pause_tracing, resume_tracing
    36 from logilab.common.debugger import Debugger
    38 from logilab.common.debugger import Debugger
    37 from logilab.common.umessage import message_from_string
    39 from logilab.common.umessage import message_from_string
    38 from logilab.common.decorators import cached, classproperty, clear_cache
    40 from logilab.common.decorators import cached, classproperty, clear_cache
    39 from logilab.common.deprecation import deprecated
    41 from logilab.common.deprecation import deprecated
    42 from cubicweb import cwconfig, devtools, web, server
    44 from cubicweb import cwconfig, devtools, web, server
    43 from cubicweb.dbapi import ProgrammingError, DBAPISession, repo_connect
    45 from cubicweb.dbapi import ProgrammingError, DBAPISession, repo_connect
    44 from cubicweb.sobjects import notification
    46 from cubicweb.sobjects import notification
    45 from cubicweb.web import Redirect, application
    47 from cubicweb.web import Redirect, application
    46 from cubicweb.server.session import security_enabled
    48 from cubicweb.server.session import security_enabled
       
    49 from cubicweb.server.hook import SendMailOp
    47 from cubicweb.devtools import SYSTEM_ENTITIES, SYSTEM_RELATIONS, VIEW_VALIDATORS
    50 from cubicweb.devtools import SYSTEM_ENTITIES, SYSTEM_RELATIONS, VIEW_VALIDATORS
    48 from cubicweb.devtools import fake, htmlparser
    51 from cubicweb.devtools import BASE_URL, fake, htmlparser
    49 from cubicweb.utils import json
    52 from cubicweb.utils import json
    50 
    53 
    51 # low-level utilities ##########################################################
    54 # low-level utilities ##########################################################
    52 
    55 
    53 class CubicWebDebugger(Debugger):
    56 class CubicWebDebugger(Debugger):
    67     """
    70     """
    68     if after is None:
    71     if after is None:
    69         after = before
    72         after = before
    70     return center - before <= line_no <= center + after
    73     return center - before <= line_no <= center + after
    71 
    74 
    72 
       
    73 def unprotected_entities(schema, strict=False):
    75 def unprotected_entities(schema, strict=False):
    74     """returned a set of each non final entity type, excluding "system" entities
    76     """returned a set of each non final entity type, excluding "system" entities
    75     (eg CWGroup, CWUser...)
    77     (eg CWGroup, CWUser...)
    76     """
    78     """
    77     if strict:
    79     if strict:
    78         protected_entities = yams.schema.BASE_TYPES
    80         protected_entities = yams.schema.BASE_TYPES
    79     else:
    81     else:
    80         protected_entities = yams.schema.BASE_TYPES.union(SYSTEM_ENTITIES)
    82         protected_entities = yams.schema.BASE_TYPES.union(SYSTEM_ENTITIES)
    81     return set(schema.entities()) - protected_entities
    83     return set(schema.entities()) - protected_entities
    82 
       
    83 
    84 
    84 def refresh_repo(repo, resetschema=False, resetvreg=False):
    85 def refresh_repo(repo, resetschema=False, resetvreg=False):
    85     for pool in repo.pools:
    86     for pool in repo.pools:
    86         pool.close(True)
    87         pool.close(True)
    87     repo.system_source.shutdown()
    88     repo.system_source.shutdown()
   141         MAILBOX.append(Email(recipients, msg))
   142         MAILBOX.append(Email(recipients, msg))
   142 
   143 
   143 cwconfig.SMTP = MockSMTP
   144 cwconfig.SMTP = MockSMTP
   144 
   145 
   145 
   146 
       
   147 class TestCaseConnectionProxy(object):
       
   148     """thin wrapper around `cubicweb.dbapi.Connection` context-manager
       
   149     used in CubicWebTC (cf. `cubicweb.devtools.testlib.CubicWebTC.login` method)
       
   150 
       
   151     It just proxies to the default connection context manager but
       
   152     restores the original connection on exit.
       
   153     """
       
   154     def __init__(self, testcase, cnx):
       
   155         self.testcase = testcase
       
   156         self.cnx = cnx
       
   157 
       
   158     def __getattr__(self, attrname):
       
   159         return getattr(self.cnx, attrname)
       
   160 
       
   161     def __enter__(self):
       
   162         return self.cnx.__enter__()
       
   163 
       
   164     def __exit__(self, exctype, exc, tb):
       
   165         try:
       
   166             return self.cnx.__exit__(exctype, exc, tb)
       
   167         finally:
       
   168             self.cnx.close()
       
   169             self.testcase.restore_connection()
       
   170 
   146 # base class for cubicweb tests requiring a full cw environments ###############
   171 # base class for cubicweb tests requiring a full cw environments ###############
   147 
   172 
   148 class CubicWebTC(TestCase):
   173 class CubicWebTC(TestCase):
   149     """abstract class for test using an apptest environment
   174     """abstract class for test using an apptest environment
   150 
   175 
   161     * `admpassword`, password of the admin user
   186     * `admpassword`, password of the admin user
   162     """
   187     """
   163     appid = 'data'
   188     appid = 'data'
   164     configcls = devtools.ApptestConfiguration
   189     configcls = devtools.ApptestConfiguration
   165     reset_schema = reset_vreg = False # reset schema / vreg between tests
   190     reset_schema = reset_vreg = False # reset schema / vreg between tests
       
   191     tags = TestCase.tags | Tags('cubicweb', 'cw_repo')
   166 
   192 
   167     @classproperty
   193     @classproperty
   168     def config(cls):
   194     def config(cls):
   169         """return the configuration object. Configuration is cached on the test
   195         """return the configuration object
   170         class.
   196 
       
   197         Configuration is cached on the test class.
   171         """
   198         """
   172         try:
   199         try:
   173             return cls.__dict__['_config']
   200             return cls.__dict__['_config']
   174         except KeyError:
   201         except KeyError:
   175             config = cls._config = cls.configcls(cls.appid)
   202             home = join(dirname(sys.modules[cls.__module__].__file__), cls.appid)
       
   203             config = cls._config = cls.configcls(cls.appid, apphome=home)
   176             config.mode = 'test'
   204             config.mode = 'test'
   177             return config
   205             return config
   178 
   206 
   179     @classmethod
   207     @classmethod
   180     def init_config(cls, config):
   208     def init_config(cls, config):
   181         """configuration initialization hooks. You may want to override this."""
   209         """configuration initialization hooks.
       
   210 
       
   211         You may only want to override here the configuraton logic.
       
   212 
       
   213         Otherwise, consider to use a different :class:`ApptestConfiguration`
       
   214         defined in the `configcls` class attribute"""
   182         source = config.sources()['system']
   215         source = config.sources()['system']
   183         cls.admlogin = unicode(source['db-user'])
   216         cls.admlogin = unicode(source['db-user'])
   184         cls.admpassword = source['db-password']
   217         cls.admpassword = source['db-password']
   185         # uncomment the line below if you want rql queries to be logged
   218         # uncomment the line below if you want rql queries to be logged
   186         #config.global_set_option('query-log-file',
   219         #config.global_set_option('query-log-file',
   198                                           or os.environ.get('LOGNAME'))
   231                                           or os.environ.get('LOGNAME'))
   199         config.global_set_option('sender-addr', send_to)
   232         config.global_set_option('sender-addr', send_to)
   200         config.global_set_option('default-dest-addrs', send_to)
   233         config.global_set_option('default-dest-addrs', send_to)
   201         config.global_set_option('sender-name', 'cubicweb-test')
   234         config.global_set_option('sender-name', 'cubicweb-test')
   202         config.global_set_option('sender-addr', 'cubicweb-test@logilab.fr')
   235         config.global_set_option('sender-addr', 'cubicweb-test@logilab.fr')
       
   236         # default_base_url on config class isn't enough for TestServerConfiguration
       
   237         config.global_set_option('base-url', config.default_base_url())
   203         # web resources
   238         # web resources
   204         config.global_set_option('base-url', devtools.BASE_URL)
       
   205         try:
   239         try:
   206             config.global_set_option('embed-allowed', re.compile('.*'))
   240             config.global_set_option('embed-allowed', re.compile('.*'))
   207         except: # not in server only configuration
   241         except: # not in server only configuration
   208             pass
   242             pass
   209 
   243 
   264         server.set_debug(debugmode)
   298         server.set_debug(debugmode)
   265 
   299 
   266     # default test setup and teardown #########################################
   300     # default test setup and teardown #########################################
   267 
   301 
   268     def setUp(self):
   302     def setUp(self):
       
   303         # monkey patch send mail operation so emails are sent synchronously
       
   304         self._old_mail_commit_event = SendMailOp.commit_event
       
   305         SendMailOp.commit_event = SendMailOp.sendmails
   269         pause_tracing()
   306         pause_tracing()
   270         previous_failure = self.__class__.__dict__.get('_repo_init_failed')
   307         previous_failure = self.__class__.__dict__.get('_repo_init_failed')
   271         if previous_failure is not None:
   308         if previous_failure is not None:
   272             self.skip('repository is not initialised: %r' % previous_failure)
   309             self.skipTest('repository is not initialised: %r' % previous_failure)
   273         try:
   310         try:
   274             self._init_repo()
   311             self._init_repo()
   275         except Exception, ex:
   312         except Exception, ex:
   276             self.__class__._repo_init_failed = ex
   313             self.__class__._repo_init_failed = ex
   277             raise
   314             raise
   285         if not self.cnx._closed:
   322         if not self.cnx._closed:
   286             self.cnx.rollback()
   323             self.cnx.rollback()
   287         for cnx in self._cnxs:
   324         for cnx in self._cnxs:
   288             if not cnx._closed:
   325             if not cnx._closed:
   289                 cnx.close()
   326                 cnx.close()
       
   327         SendMailOp.commit_event = self._old_mail_commit_event
   290 
   328 
   291     def setup_database(self):
   329     def setup_database(self):
   292         """add your database setup code by overriding this method"""
   330         """add your database setup code by overriding this method"""
   293 
   331 
   294     # user / session management ###############################################
   332     # user / session management ###############################################
   311         user = req.create_entity('CWUser', login=unicode(login),
   349         user = req.create_entity('CWUser', login=unicode(login),
   312                                  upassword=password, **kwargs)
   350                                  upassword=password, **kwargs)
   313         req.execute('SET X in_group G WHERE X eid %%(x)s, G name IN(%s)'
   351         req.execute('SET X in_group G WHERE X eid %%(x)s, G name IN(%s)'
   314                     % ','.join(repr(g) for g in groups),
   352                     % ','.join(repr(g) for g in groups),
   315                     {'x': user.eid})
   353                     {'x': user.eid})
   316         user.clear_related_cache('in_group', 'subject')
   354         user.cw_clear_relation_cache('in_group', 'subject')
   317         if commit:
   355         if commit:
   318             req.cnx.commit()
   356             req.cnx.commit()
   319         return user
   357         return user
   320 
   358 
   321     def login(self, login, **kwargs):
   359     def login(self, login, **kwargs):
   322         """return a connection for the given login/password"""
   360         """return a connection for the given login/password"""
   323         if login == self.admlogin:
   361         if login == self.admlogin:
   324             self.restore_connection()
   362             self.restore_connection()
   325         else:
   363             # definitly don't want autoclose when used as a context manager
   326             if not kwargs:
   364             return self.cnx
   327                 kwargs['password'] = str(login)
   365         autoclose = kwargs.pop('autoclose', True)
   328             self.cnx = repo_connect(self.repo, unicode(login), **kwargs)
   366         if not kwargs:
   329             self.websession = DBAPISession(self.cnx)
   367             kwargs['password'] = str(login)
   330             self._cnxs.append(self.cnx)
   368         self.cnx = repo_connect(self.repo, unicode(login), **kwargs)
       
   369         self.websession = DBAPISession(self.cnx)
       
   370         self._cnxs.append(self.cnx)
   331         if login == self.vreg.config.anonymous_user()[0]:
   371         if login == self.vreg.config.anonymous_user()[0]:
   332             self.cnx.anonymous_connection = True
   372             self.cnx.anonymous_connection = True
       
   373         if autoclose:
       
   374             return TestCaseConnectionProxy(self, self.cnx)
   333         return self.cnx
   375         return self.cnx
   334 
   376 
   335     def restore_connection(self):
   377     def restore_connection(self):
   336         if not self.cnx is self._orig_cnx[0]:
   378         if not self.cnx is self._orig_cnx[0]:
   337             if not self.cnx._closed:
   379             if not self.cnx._closed:
   497             raise
   539             raise
   498         publisher.error_handler = raise_error_handler
   540         publisher.error_handler = raise_error_handler
   499         return publisher
   541         return publisher
   500 
   542 
   501     requestcls = fake.FakeRequest
   543     requestcls = fake.FakeRequest
   502     def request(self, *args, **kwargs):
   544     def request(self, rollbackfirst=False, **kwargs):
   503         """return a web ui request"""
   545         """return a web ui request"""
   504         req = self.requestcls(self.vreg, form=kwargs)
   546         req = self.requestcls(self.vreg, form=kwargs)
       
   547         if rollbackfirst:
       
   548             self.websession.cnx.rollback()
   505         req.set_session(self.websession)
   549         req.set_session(self.websession)
   506         return req
   550         return req
   507 
   551 
   508     def remote_call(self, fname, *args):
   552     def remote_call(self, fname, *args):
   509         """remote json call simulation"""
   553         """remote json call simulation"""
   524             req.cnx.commit()
   568             req.cnx.commit()
   525         except web.Redirect:
   569         except web.Redirect:
   526             req.cnx.commit()
   570             req.cnx.commit()
   527             raise
   571             raise
   528         return result
   572         return result
       
   573 
       
   574     def req_from_url(self, url):
       
   575         """parses `url` and builds the corresponding CW-web request
       
   576 
       
   577         req.form will be setup using the url's query string
       
   578         """
       
   579         req = self.request()
       
   580         if isinstance(url, unicode):
       
   581             url = url.encode(req.encoding) # req.setup_params() expects encoded strings
       
   582         querystring = urlparse.urlparse(url)[-2]
       
   583         params = urlparse.parse_qs(querystring)
       
   584         req.setup_params(params)
       
   585         return req
       
   586 
       
   587     def url_publish(self, url):
       
   588         """takes `url`, uses application's app_resolver to find the
       
   589         appropriate controller, and publishes the result.
       
   590 
       
   591         This should pretty much correspond to what occurs in a real CW server
       
   592         except the apache-rewriter component is not called.
       
   593         """
       
   594         req = self.req_from_url(url)
       
   595         ctrlid, rset = self.app.url_resolver.process(req, req.relative_path(False))
       
   596         return self.ctrl_publish(req, ctrlid)
   529 
   597 
   530     def expect_redirect(self, callback, req):
   598     def expect_redirect(self, callback, req):
   531         """call the given callback with req as argument, expecting to get a
   599         """call the given callback with req as argument, expecting to get a
   532         Redirect exception
   600         Redirect exception
   533         """
   601         """
   571 
   639 
   572     def assertAuthSuccess(self, req, origsession, nbsessions=1):
   640     def assertAuthSuccess(self, req, origsession, nbsessions=1):
   573         sh = self.app.session_handler
   641         sh = self.app.session_handler
   574         path, params = self.expect_redirect(lambda x: self.app.connect(x), req)
   642         path, params = self.expect_redirect(lambda x: self.app.connect(x), req)
   575         session = req.session
   643         session = req.session
   576         self.assertEquals(len(self.open_sessions), nbsessions, self.open_sessions)
   644         self.assertEqual(len(self.open_sessions), nbsessions, self.open_sessions)
   577         self.assertEquals(session.login, origsession.login)
   645         self.assertEqual(session.login, origsession.login)
   578         self.assertEquals(session.anonymous_session, False)
   646         self.assertEqual(session.anonymous_session, False)
   579         self.assertEquals(path, 'view')
   647         self.assertEqual(path, 'view')
   580         self.assertEquals(params, {'__message': 'welcome %s !' % req.user.login})
   648         self.assertEqual(params, {'__message': 'welcome %s !' % req.user.login})
   581 
   649 
   582     def assertAuthFailure(self, req, nbsessions=0):
   650     def assertAuthFailure(self, req, nbsessions=0):
   583         self.app.connect(req)
   651         self.app.connect(req)
   584         self.assertIsInstance(req.session, DBAPISession)
   652         self.assertIsInstance(req.session, DBAPISession)
   585         self.assertEquals(req.session.cnx, None)
   653         self.assertEqual(req.session.cnx, None)
   586         self.assertEquals(req.cnx, None)
   654         self.assertEqual(req.cnx, None)
   587         self.assertEquals(len(self.open_sessions), nbsessions)
   655         self.assertEqual(len(self.open_sessions), nbsessions)
   588         clear_cache(req, 'get_authorization')
   656         clear_cache(req, 'get_authorization')
   589 
   657 
   590     # content validation #######################################################
   658     # content validation #######################################################
   591 
   659 
   592     # validators are used to validate (XML, DTD, whatever) view's content
   660     # validators are used to validate (XML, DTD, whatever) view's content
   618 
   686 
   619     def view(self, vid, rset=None, req=None, template='main-template',
   687     def view(self, vid, rset=None, req=None, template='main-template',
   620              **kwargs):
   688              **kwargs):
   621         """This method tests the view `vid` on `rset` using `template`
   689         """This method tests the view `vid` on `rset` using `template`
   622 
   690 
   623         If no error occured while rendering the view, the HTML is analyzed
   691         If no error occurred while rendering the view, the HTML is analyzed
   624         and parsed.
   692         and parsed.
   625 
   693 
   626         :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo`
   694         :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo`
   627                   encapsulation the generated HTML
   695                   encapsulation the generated HTML
   628         """
   696         """
   631         kwargs['rset'] = rset
   699         kwargs['rset'] = rset
   632         viewsreg = self.vreg['views']
   700         viewsreg = self.vreg['views']
   633         view = viewsreg.select(vid, req, **kwargs)
   701         view = viewsreg.select(vid, req, **kwargs)
   634         # set explicit test description
   702         # set explicit test description
   635         if rset is not None:
   703         if rset is not None:
   636             self.set_description("testing %s, mod=%s (%s)" % (
   704             self.set_description("testing vid=%s defined in %s with (%s)" % (
   637                 vid, view.__module__, rset.printable_rql()))
   705                 vid, view.__module__, rset.printable_rql()))
   638         else:
   706         else:
   639             self.set_description("testing %s, mod=%s (no rset)" % (
   707             self.set_description("testing vid=%s defined in %s without rset" % (
   640                 vid, view.__module__))
   708                 vid, view.__module__))
   641         if template is None: # raw view testing, no template
   709         if template is None: # raw view testing, no template
   642             viewfunc = view.render
   710             viewfunc = view.render
   643         else:
   711         else:
   644             kwargs['view'] = view
   712             kwargs['view'] = view
   650 
   718 
   651 
   719 
   652     def _test_view(self, viewfunc, view, template='main-template', kwargs={}):
   720     def _test_view(self, viewfunc, view, template='main-template', kwargs={}):
   653         """this method does the actual call to the view
   721         """this method does the actual call to the view
   654 
   722 
   655         If no error occured while rendering the view, the HTML is analyzed
   723         If no error occurred while rendering the view, the HTML is analyzed
   656         and parsed.
   724         and parsed.
   657 
   725 
   658         :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo`
   726         :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo`
   659                   encapsulation the generated HTML
   727                   encapsulation the generated HTML
   660         """
   728         """
   702             else:
   770             else:
   703                 default_validator = None
   771                 default_validator = None
   704             validatorclass = self.content_type_validators.get(view.content_type,
   772             validatorclass = self.content_type_validators.get(view.content_type,
   705                                                               default_validator)
   773                                                               default_validator)
   706         if validatorclass is None:
   774         if validatorclass is None:
   707             return None
   775             return output.strip()
   708         validator = validatorclass()
   776         validator = validatorclass()
   709         if isinstance(validator, htmlparser.DTDValidator):
   777         if isinstance(validator, htmlparser.DTDValidator):
   710             # XXX remove <canvas> used in progress widget, unknown in html dtd
   778             # XXX remove <canvas> used in progress widget, unknown in html dtd
   711             output = re.sub('<canvas.*?></canvas>', '', output)
   779             output = re.sub('<canvas.*?></canvas>', '', output)
   712         return validator.parse_string(output.strip())
   780         return validator.parse_string(output.strip())
   784 
   852 
   785 class AutoPopulateTest(CubicWebTC):
   853 class AutoPopulateTest(CubicWebTC):
   786     """base class for test with auto-populating of the database"""
   854     """base class for test with auto-populating of the database"""
   787     __abstract__ = True
   855     __abstract__ = True
   788 
   856 
       
   857     tags = CubicWebTC.tags | Tags('autopopulated')
       
   858 
   789     pdbclass = CubicWebDebugger
   859     pdbclass = CubicWebDebugger
   790     # this is a hook to be able to define a list of rql queries
   860     # this is a hook to be able to define a list of rql queries
   791     # that are application dependent and cannot be guessed automatically
   861     # that are application dependent and cannot be guessed automatically
   792     application_rql = []
   862     application_rql = []
   793 
   863 
   840             try:
   910             try:
   841                 cu.execute(rql, args)
   911                 cu.execute(rql, args)
   842             except ValidationError, ex:
   912             except ValidationError, ex:
   843                 # failed to satisfy some constraint
   913                 # failed to satisfy some constraint
   844                 print 'error in automatic db population', ex
   914                 print 'error in automatic db population', ex
       
   915                 self.session.commit_state = None # reset uncommitable flag
   845         self.post_populate(cu)
   916         self.post_populate(cu)
   846         self.commit()
   917         self.commit()
   847 
   918 
   848     def iter_individual_rsets(self, etypes=None, limit=None):
   919     def iter_individual_rsets(self, etypes=None, limit=None):
   849         etypes = etypes or self.to_test_etypes()
   920         etypes = etypes or self.to_test_etypes()
   909 
   980 
   910 # concrete class for automated application testing  ############################
   981 # concrete class for automated application testing  ############################
   911 
   982 
   912 class AutomaticWebTest(AutoPopulateTest):
   983 class AutomaticWebTest(AutoPopulateTest):
   913     """import this if you wan automatic tests to be ran"""
   984     """import this if you wan automatic tests to be ran"""
       
   985 
       
   986     tags = AutoPopulateTest.tags | Tags('web', 'generated')
       
   987 
   914     def setUp(self):
   988     def setUp(self):
   915         AutoPopulateTest.setUp(self)
   989         AutoPopulateTest.setUp(self)
   916         # access to self.app for proper initialization of the authentication
   990         # access to self.app for proper initialization of the authentication
   917         # machinery (else some views may fail)
   991         # machinery (else some views may fail)
   918         self.app
   992         self.app